fufu-cloud-cli 0.5.0__tar.gz → 0.7.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 (30) hide show
  1. {fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.7.0}/PKG-INFO +40 -5
  2. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/README.md +39 -4
  3. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/capabilities.json +13 -1
  6. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cli.py +39 -10
  7. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/docker_backend.py +64 -6
  8. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/manifest.py +11 -3
  9. fufu_cloud_cli-0.7.0/src/fufu_cloud_cli/public_access.py +171 -0
  10. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/remote_compute.py +27 -0
  11. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0/src/fufu_cloud_cli.egg-info}/PKG-INFO +40 -5
  12. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
  13. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/LICENSE +0 -0
  14. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/MANIFEST.in +0 -0
  15. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/setup.cfg +0 -0
  16. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__main__.py +0 -0
  17. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/api_backend.py +0 -0
  18. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
  19. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/control.py +0 -0
  20. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/errors.py +0 -0
  21. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  22. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  23. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  24. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  25. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  26. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/serverless.py +0 -0
  27. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  28. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  29. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  30. {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.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.5.0
3
+ Version: 0.7.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
@@ -69,7 +69,8 @@ credential 內含 FUFU endpoint。`FUFU_ENDPOINT` 可明確覆寫目的地:僅
69
69
  一般命令交換短期權杖並在結束時嘗試撤銷。`fufu auth assume-role`
70
70
  可建立受限 session 檔,檔案以排他方式建立且權限為 `0600`;請勿提交。
71
71
  FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制存取。
72
- 原生雲端的 GitHub auth actions、OIDC、SigV4 和 OAuth 不直接相容。
72
+ 原生雲端的 GitHub auth actions、OIDC 和 OAuth 不直接相容。
73
+ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header signing;管理 API 仍使用 FUFU token。
73
74
 
74
75
  ## 部署與支援範圍
75
76
 
@@ -77,21 +78,32 @@ FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制
77
78
  fufu capabilities
78
79
  fufu capabilities --remote --provider gcp --require cloud-run-http
79
80
  gcloud-fufu run deploy example --image approved-image:tag
81
+ gcloud-fufu run deploy example --source ./prepared-asgi.zip --entry-point main:app
80
82
  gcloud-fufu functions deploy example-function --gen2 \
81
83
  --runtime python312 --source ./function --entry-point handler --trigger-http
82
84
  aws-fufu lambda list-functions
83
85
  ```
84
86
 
85
- 映像需先由 FUFU 管理者準備及核准。Function 來源會上傳指定目錄中的
87
+ 自訂映像需先由 FUFU 管理者準備及核准。API 模式的 Cloud Run `--source`
88
+ 接受已封裝 Uvicorn 與相依套件的 Python 3.12 ASGI ZIP/乾淨目錄,自動離線建置、
89
+ 檢查映像並核准到目前租戶;不需要主機 SSH 或每次人工核准。
90
+ `--entry-point module:app` 是 FUFU 擴充旗標,預設 `main:app`。
91
+ 來源上限為 ZIP 6 MiB、解壓縮 32 MiB、2000 個檔案;不接受隱藏檔、Dockerfile、
92
+ 自訂安裝設定或非空 requirements.txt。相依套件需在 CI 封裝,服務端不執行安裝 hook。
93
+ 這是固定 ASGI 配方,不提供完整 Cloud Build 或 registry API。
94
+ 成功回應包含 `build.status`、`imageDigest` 與來源包雜湊;自動核准映像
95
+ 僅供該租戶目前服務使用,取代或刪除最後一個引用後不再開放重用。
96
+
97
+ Function 來源會上傳指定目錄中的
86
98
  Python 與 requirements 檔;Lambda 會上傳指定的 ZIP。請使用只含必要程式碼的
87
99
  目錄或 ZIP,先移除硬編碼秘密、個人資料與不需要的檔案。
88
100
  CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
89
101
 
90
102
  | 服務 | 可用範圍與主要限制 |
91
103
  | --- | --- |
92
- | Cloud Run | 單一已核准 HTTP 容器;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
104
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
93
105
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
94
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新;遠端 ZIP 上限 6 MiB |
106
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;遠端 ZIP 上限 6 MiB |
95
107
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
96
108
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
97
109
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -107,11 +119,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
107
119
 
108
120
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
109
121
  `metadata.name`、單一 container 的 image / port / env,以及明列的 service account。
122
+ 服務 metadata.annotations 支援 `run.googleapis.com/ingress`(all/internal)與
123
+ `run.googleapis.com/invoker-iam-disabled`(字串 true/false)。
110
124
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
111
125
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
112
126
 
113
127
  ## 隱私與執行邊界
114
128
 
129
+ 0.7.0 新增服務公開存取。需由平台配置 DNS、HTTPS 入口與 WAF 放行,
130
+ `publicAccess.status=ready` 表示入口已配置;仍應由 CI 實際 HTTP 呼叫確認外部可達。
131
+
132
+ ```bash
133
+ gcloud-fufu run services update example --allow-unauthenticated --ingress all
134
+ gcloud-fufu run services get-iam-policy example
135
+ gcloud-fufu run services update example --no-allow-unauthenticated
136
+ gcloud-fufu auth print-identity-token --audiences https://app-SERVICE_ID.example.com
137
+ aws-fufu lambda create-function-url-config --function-name example --auth-type AWS_IAM
138
+ aws-fufu lambda get-function-url-config --function-name example
139
+ ```
140
+
141
+ UI 與 CLI 共用同一份服務政策。Cloud Run 與 HTTP Functions 的匿名政策使用
142
+ `allUsers` 與對應 Invoker 角色。Lambda `NONE` 必須另加 `lambda:InvokeFunctionUrl`
143
+ (`--function-url-auth-type NONE`)和 `lambda:InvokeFunction`
144
+ (`--invoked-via-function-url`)兩項資源權限;只設定 NONE 不會自動開放呼叫。
145
+ 入口支援有界限的普通 HTTP:請求與回應各 6 MiB、執行 15 秒,每服務每分鐘 120 次。
146
+ 不支援 SSE、WebSocket、串流、STS 暫時金鑰、presigned URL 或真實 VPC。
147
+ GCP ID token 由 FUFU 的 RS256 issuer 簽發,綁定精確 audience,15 分鐘後過期;不接受 Google ID token。
148
+ `print-identity-token` 明確輸出秘密,應直接放入受控變數,避免 CI 日誌或 shell tracing。
149
+
115
150
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
116
151
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
117
152
  - `whoami` 會輸出目前租戶、provider 與 scope;查詢/invoke 會輸出所要求的資料。
@@ -56,7 +56,8 @@ credential 內含 FUFU endpoint。`FUFU_ENDPOINT` 可明確覆寫目的地:僅
56
56
  一般命令交換短期權杖並在結束時嘗試撤銷。`fufu auth assume-role`
57
57
  可建立受限 session 檔,檔案以排他方式建立且權限為 `0600`;請勿提交。
58
58
  FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制存取。
59
- 原生雲端的 GitHub auth actions、OIDC、SigV4 和 OAuth 不直接相容。
59
+ 原生雲端的 GitHub auth actions、OIDC 和 OAuth 不直接相容。
60
+ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header signing;管理 API 仍使用 FUFU token。
60
61
 
61
62
  ## 部署與支援範圍
62
63
 
@@ -64,21 +65,32 @@ FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制
64
65
  fufu capabilities
65
66
  fufu capabilities --remote --provider gcp --require cloud-run-http
66
67
  gcloud-fufu run deploy example --image approved-image:tag
68
+ gcloud-fufu run deploy example --source ./prepared-asgi.zip --entry-point main:app
67
69
  gcloud-fufu functions deploy example-function --gen2 \
68
70
  --runtime python312 --source ./function --entry-point handler --trigger-http
69
71
  aws-fufu lambda list-functions
70
72
  ```
71
73
 
72
- 映像需先由 FUFU 管理者準備及核准。Function 來源會上傳指定目錄中的
74
+ 自訂映像需先由 FUFU 管理者準備及核准。API 模式的 Cloud Run `--source`
75
+ 接受已封裝 Uvicorn 與相依套件的 Python 3.12 ASGI ZIP/乾淨目錄,自動離線建置、
76
+ 檢查映像並核准到目前租戶;不需要主機 SSH 或每次人工核准。
77
+ `--entry-point module:app` 是 FUFU 擴充旗標,預設 `main:app`。
78
+ 來源上限為 ZIP 6 MiB、解壓縮 32 MiB、2000 個檔案;不接受隱藏檔、Dockerfile、
79
+ 自訂安裝設定或非空 requirements.txt。相依套件需在 CI 封裝,服務端不執行安裝 hook。
80
+ 這是固定 ASGI 配方,不提供完整 Cloud Build 或 registry API。
81
+ 成功回應包含 `build.status`、`imageDigest` 與來源包雜湊;自動核准映像
82
+ 僅供該租戶目前服務使用,取代或刪除最後一個引用後不再開放重用。
83
+
84
+ Function 來源會上傳指定目錄中的
73
85
  Python 與 requirements 檔;Lambda 會上傳指定的 ZIP。請使用只含必要程式碼的
74
86
  目錄或 ZIP,先移除硬編碼秘密、個人資料與不需要的檔案。
75
87
  CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
76
88
 
77
89
  | 服務 | 可用範圍與主要限制 |
78
90
  | --- | --- |
79
- | Cloud Run | 單一已核准 HTTP 容器;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
91
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
80
92
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
81
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新;遠端 ZIP 上限 6 MiB |
93
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;遠端 ZIP 上限 6 MiB |
82
94
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
83
95
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
84
96
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -94,11 +106,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
94
106
 
95
107
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
96
108
  `metadata.name`、單一 container 的 image / port / env,以及明列的 service account。
109
+ 服務 metadata.annotations 支援 `run.googleapis.com/ingress`(all/internal)與
110
+ `run.googleapis.com/invoker-iam-disabled`(字串 true/false)。
97
111
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
98
112
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
99
113
 
100
114
  ## 隱私與執行邊界
101
115
 
116
+ 0.7.0 新增服務公開存取。需由平台配置 DNS、HTTPS 入口與 WAF 放行,
117
+ `publicAccess.status=ready` 表示入口已配置;仍應由 CI 實際 HTTP 呼叫確認外部可達。
118
+
119
+ ```bash
120
+ gcloud-fufu run services update example --allow-unauthenticated --ingress all
121
+ gcloud-fufu run services get-iam-policy example
122
+ gcloud-fufu run services update example --no-allow-unauthenticated
123
+ gcloud-fufu auth print-identity-token --audiences https://app-SERVICE_ID.example.com
124
+ aws-fufu lambda create-function-url-config --function-name example --auth-type AWS_IAM
125
+ aws-fufu lambda get-function-url-config --function-name example
126
+ ```
127
+
128
+ UI 與 CLI 共用同一份服務政策。Cloud Run 與 HTTP Functions 的匿名政策使用
129
+ `allUsers` 與對應 Invoker 角色。Lambda `NONE` 必須另加 `lambda:InvokeFunctionUrl`
130
+ (`--function-url-auth-type NONE`)和 `lambda:InvokeFunction`
131
+ (`--invoked-via-function-url`)兩項資源權限;只設定 NONE 不會自動開放呼叫。
132
+ 入口支援有界限的普通 HTTP:請求與回應各 6 MiB、執行 15 秒,每服務每分鐘 120 次。
133
+ 不支援 SSE、WebSocket、串流、STS 暫時金鑰、presigned URL 或真實 VPC。
134
+ GCP ID token 由 FUFU 的 RS256 issuer 簽發,綁定精確 audience,15 分鐘後過期;不接受 Google ID token。
135
+ `print-identity-token` 明確輸出秘密,應直接放入受控變數,避免 CI 日誌或 shell tracing。
136
+
102
137
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
103
138
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
104
139
  - `whoami` 會輸出目前租戶、provider 與 scope;查詢/invoke 會輸出所要求的資料。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fufu-cloud-cli"
7
- version = "0.5.0"
7
+ version = "0.7.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"
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -11,6 +11,8 @@
11
11
  "status": "implemented",
12
12
  "operations": [
13
13
  "deploy-image",
14
+ "deploy-python312-asgi-source-api",
15
+ "automatic-tenant-image-admission",
14
16
  "replace-service-subset",
15
17
  "describe",
16
18
  "list",
@@ -19,13 +21,16 @@
19
21
  ],
20
22
  "limitations": [
21
23
  "single-approved-container",
24
+ "source-prepackaged-dependencies-6MiB",
25
+ "source-fixed-recipe-no-custom-Dockerfile",
26
+ "source-approval-current-tenant-services-only",
22
27
  "GCP-service-account-subset",
23
28
  "explicit-GCS-workload-helper",
24
29
  "no-registry-upload",
25
30
  "no-autoscaling",
26
31
  "no-traffic-splitting",
27
32
  "no-jobs",
28
- "no-public-ingress"
33
+ "public-ingress-requires-configured-api-gateway"
29
34
  ],
30
35
  "backends": [
31
36
  "local-docker",
@@ -77,6 +82,13 @@
77
82
  "api"
78
83
  ]
79
84
  },
85
+ "public-http-access": {
86
+ "status": "implemented",
87
+ "backend": "api",
88
+ "mode": "simplified",
89
+ "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"],
90
+ "limitations": ["operator-DNS-TLS-WAF-required", "Cloud-Run-and-HTTP-Functions-and-Lambda-only", "buffered-6MiB-15s", "no-WebSocket-or-SSE", "no-STS-or-presigned-URL", "FUFU-ID-token-issuer-not-Google", "internal-means-platform-not-VPC", "120-requests-per-minute-per-service", "no-custom-domain-or-CORS-management", "no-full-cloud-IAM-equivalence"]
91
+ },
80
92
  "serverless-framework": {
81
93
  "status": "unsupported",
82
94
  "reason": "未實作原生 Framework provider/plugins/CloudFormation;可改用明確的 serverless-profile-adapter 子集。"
@@ -12,7 +12,8 @@ from .docker_backend import (Backend, FUNCTIONS_IMAGE, INGRESS_IMAGE, KINDS, LAM
12
12
  inspect_image, prepare_runtime)
13
13
  from .errors import FufuError, require
14
14
  from .api_backend import ApiClient, api_selected, remote_command
15
- from .remote_compute import RemoteBackend, source_archive, lambda_archive
15
+ from .remote_compute import RemoteBackend, source_archive, lambda_archive, cloud_run_archive
16
+ from .public_access import deployment_flags, run_iam_flags, configuration, configure_gcp, configure_manifest
16
17
 
17
18
 
18
19
  class Parser(argparse.ArgumentParser):
@@ -177,9 +178,10 @@ def fufu_command(argv):
177
178
  "images": available, "auth": "local-docker-permission", "remote_cloud": False}
178
179
  if a.action == "deploy":
179
180
  spec = manifest.load(a.file)
181
+ configure_manifest(spec)
180
182
  spec["env"].update(env_from_names(a.env))
181
- return compute_backend('cloud-run-http').deploy(kind="cloud-run-http", service=spec["name"], image=spec["image"],
182
- port=spec["port"], env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {}))
183
+ return configure_manifest(spec, compute_backend('cloud-run-http').deploy(kind="cloud-run-http", service=spec["name"], image=spec["image"],
184
+ port=spec["port"], env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {})))
183
185
  if a.action == "down":
184
186
  require(not api_selected(), 'UNSUPPORTED_ARGUMENT', 'API 模式請逐一刪除指定服務;不提供整個租戶清空。')
185
187
  return Backend().down()
@@ -204,10 +206,14 @@ def gcloud_command(argv):
204
206
  run_sub = run_p.add_subparsers(dest="action", required=True)
205
207
  d = run_sub.add_parser("deploy")
206
208
  d.add_argument("name")
207
- d.add_argument("--image", required=True)
209
+ source = d.add_mutually_exclusive_group(required=True)
210
+ source.add_argument('--image')
211
+ source.add_argument('--source', help='已封裝相依的 Python 3.12 ASGI ZIP/目錄;API 自動建置及核准')
212
+ d.add_argument('--entry-point', default='main:app', help='FUFU ASGI 來源子集,預設 main:app')
208
213
  d.add_argument("--port", type=int, default=8080)
209
214
  d.add_argument('--service-account')
210
215
  deployment_options(d)
216
+ deployment_flags(d); run_iam_flags(d)
211
217
  services = run_sub.add_parser("services").add_subparsers(dest="service_action", required=True)
212
218
  replace = services.add_parser("replace")
213
219
  replace.add_argument("file")
@@ -225,6 +231,7 @@ def gcloud_command(argv):
225
231
  generation_options(d)
226
232
  d.add_argument("--trigger-http", required=True, action="store_true")
227
233
  deployment_options(d)
234
+ deployment_flags(d)
228
235
  for action in ("describe", "delete", "call"):
229
236
  q = fn.add_parser(action)
230
237
  q.add_argument("name")
@@ -237,18 +244,28 @@ def gcloud_command(argv):
237
244
  action = a.service_action if a.action == "services" else a.action
238
245
  if action == "replace":
239
246
  spec = manifest.load(a.file)
247
+ configure_manifest(spec)
240
248
  spec["env"].update(env_from_names(a.env))
241
- return compute_backend(kind).deploy(kind=kind, service=spec["name"], image=spec["image"], port=spec["port"],
242
- env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {}))
249
+ return configure_manifest(spec, compute_backend(kind).deploy(kind=kind, service=spec["name"], image=spec["image"], port=spec["port"],
250
+ env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {})))
243
251
  if action == "deploy":
244
252
  manifest.name(a.name)
253
+ configuration(a, kind)
254
+ finish = lambda deployed: configure_gcp(a, kind, a.name, deployed)
245
255
  env = env_from_names(a.env)
256
+ if a.service == 'run' and a.source:
257
+ require(api_selected(), 'API_BACKEND_REQUIRED', 'Cloud Run --source 自動核准需要 FUFU API 模式。')
258
+ return finish(RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env, port=a.port,
259
+ archive=cloud_run_archive(a.source), runtime='python312', handler=a.entry_point,
260
+ startup_timeout=a.startup_timeout, role=a.service_account))
261
+ if a.service == 'run':
262
+ require(a.entry_point == 'main:app', 'UNSUPPORTED_ARGUMENT', '--entry-point 只用於 --source。')
246
263
  if a.service == 'functions' and api_selected():
247
- return RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env,
248
- archive=source_archive(a.source), runtime=a.runtime, handler=a.entry_point, startup_timeout=a.startup_timeout, role=a.service_account)
264
+ return finish(RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env,
265
+ archive=source_archive(a.source), runtime=a.runtime, handler=a.entry_point, startup_timeout=a.startup_timeout, role=a.service_account))
249
266
  image = a.image if a.service == "run" else build_function(a.source, a.entry_point)
250
- return compute_backend(kind).deploy(kind=kind, service=a.name, image=image, port=getattr(a, "port", 8080),
251
- env=env, startup_timeout=a.startup_timeout, **({'role': a.service_account} if a.service_account else {}))
267
+ return finish(compute_backend(kind).deploy(kind=kind, service=a.name, image=image, port=getattr(a, "port", 8080),
268
+ env=env, startup_timeout=a.startup_timeout, **({'role': a.service_account} if a.service_account else {})))
252
269
  b = compute_backend(kind)
253
270
  if action == "describe":
254
271
  return b.describe(kind, a.name)
@@ -361,6 +378,18 @@ def entry(provider, argv=None):
361
378
  require('=' not in argv[i], 'UNSUPPORTED_ARGUMENT', '布林旗標不接受額外值。'); i += 1
362
379
  else: rest.append(argv[i]); i += 1
363
380
  argv = rest
381
+ from . import public_access
382
+ public_command = None
383
+ if provider == 'aws-fufu' and len(argv) > 1 and argv[0] == 'lambda' and argv[1] in public_access.AWS_ACTIONS:
384
+ public_command = public_access.aws_command
385
+ if provider == 'gcloud-fufu' and (argv[:2] == ['auth', 'print-identity-token']
386
+ or len(argv) > 2 and argv[:2] == ['run', 'services'] and argv[2] in public_access.GCP_POLICY_ACTIONS | {'update'}
387
+ or len(argv) > 1 and argv[0] == 'functions' and argv[1] in public_access.GCP_POLICY_ACTIONS):
388
+ public_command = public_access.gcp_command
389
+ if public_command:
390
+ result = public_command(argv, Parser)
391
+ if type(result) is int: raise SystemExit(result)
392
+ emit(result); return
364
393
  if provider == 'aws-fufu' and argv and argv[0] == 'serverless':
365
394
  from .serverless import command
366
395
  emit(command(argv[1:], Parser)); return
@@ -228,15 +228,19 @@ class Backend:
228
228
  require(len(ports) == 1 and ports[0]["HostIp"] == "127.0.0.1", "PORT_MISMATCH", "服務必須只綁定 loopback。")
229
229
  return f"http://127.0.0.1:{int(ports[0]['HostPort'])}"
230
230
 
231
+ def service_id(self, r):
232
+ return r.get('serviceId') or hashlib.sha256(json.dumps(
233
+ [self.workspace, r['kind'], r['name'], r['revision']]).encode()).hexdigest()[:32]
234
+
231
235
  def public(self, r):
232
236
  return {"name": r["name"], "kind": r["kind"], "workspace": self.workspace,
233
- "revision": r["revision"], "url": self.url(r), "readiness": "tcp",
234
- **{k: r[k] for k in ("runtime", "handler", "role", "sdkTransport", "iamEnforced") if k in r},
237
+ "revision": r["revision"], "serviceId": self.service_id(r), "url": self.url(r), "readiness": "tcp",
238
+ **{k: r[k] for k in ("runtime", "handler", "role", "sdkTransport", "iamEnforced", "build") if k in r},
235
239
  "status": "running", "backend": "local-docker"}
236
240
 
237
241
  def deploy(self, *, kind, service, image, port=8080, env=None, command=None,
238
242
  startup_timeout=30, replace=True, require_existing=False, runtime=None, handler=None, preserve_environment=False,
239
- runtime_env=None, role=None, gateway_container=None):
243
+ runtime_env=None, role=None, gateway_container=None, build=None):
240
244
  name(service)
241
245
  require(kind in KINDS, "UNSUPPORTED_SERVICE", "未支援此服務。")
242
246
  require(type(port) is int and 1024 <= port <= 65535, "INVALID_PORT", "無效的容器 port。")
@@ -298,10 +302,11 @@ class Backend:
298
302
  # 環境值經子程序環境注入,不出現在命令列、狀態或部署輸出。
299
303
  candidate = docker(*args, env=supplied)
300
304
  record = {"name": service, "kind": kind, "container_id": candidate,
301
- "port": port, "revision": revision,
305
+ "port": port, "revision": revision, "serviceId": self.service_id(old) if old else uuid.uuid4().hex,
302
306
  **({'network': network, 'gateway_id': gateway['id']} if gateway else {}),
303
307
  **({'role': role, 'sdkTransport': 'fufu-workload-token', 'iamEnforced': True} if role else {}),
304
- **({"environment_names": sorted(env)} if kind != 'cloud-run-http' else {}),
308
+ **({'build': build} if build else {}),
309
+ "environment_names": sorted(env),
305
310
  **({"runtime": runtime, "handler": handler} if runtime is not None else {})}
306
311
  deadline = time.monotonic() + startup_timeout
307
312
  while True:
@@ -422,12 +427,14 @@ class Backend:
422
427
  docker("network", "rm", network)
423
428
  return {"workspace": self.workspace, "removed_containers": len(ids)}
424
429
 
425
- def invoke(self, kind, service, payload, *, path="/", timeout=15):
430
+ def invoke(self, kind, service, payload, *, path="/", timeout=15, expected_service_id=None):
426
431
  require(1 <= timeout <= 120, "INVALID_TIMEOUT", "請求期限須介於 1–120 秒。")
427
432
  require(len(payload) <= MAX_PAYLOAD, "PAYLOAD_LIMIT", "Payload 超過 6 MiB。")
428
433
  require(isinstance(path, str) and path.startswith("/") and not path.startswith("//")
429
434
  and "\r" not in path and "\n" not in path, "INVALID_PATH", "只能使用本機服務相對路徑。")
430
435
  data = self.describe(kind, service)
436
+ require(expected_service_id is None or data.get('serviceId') == expected_service_id,
437
+ 'NOT_FOUND', '應用網址已失效。')
431
438
  route = "/2015-03-31/functions/function/invocations" if kind == "aws-lambda-sync" else path
432
439
 
433
440
  class NoRedirect(request.HTTPRedirectHandler):
@@ -576,3 +583,54 @@ def build_lambda(archive, runtime, handler):
576
583
  sdk.chmod(0o644)
577
584
  (dst / "Dockerfile").write_text(f"FROM {base}\nENV FUFU_HANDLER={handler}\nCOPY --chown=10001:10001 app/ ${{LAMBDA_TASK_ROOT}}/\nCMD " + json.dumps(["_fufu_runtime_adapter.handler"]) + "\n")
578
585
  return build_image(dst, f"fufu-cli-lambda:{uuid.uuid4().hex}")
586
+
587
+
588
+ def build_cloud_run(archive, handler, port, tag):
589
+ """固定 Python ASGI 配方;不執行使用者 Dockerfile、安裝程式或建置 hook。"""
590
+ require(isinstance(handler, str) and re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", handler),
591
+ 'INVALID_HANDLER', 'ASGI entry point 必須是 module:app。')
592
+ require(type(port) is int and 1024 <= port <= 65535, 'INVALID_PORT', '容器 port 範圍為 1024–65535。')
593
+ require(re.fullmatch(r'fufu-source:[a-f0-9]{64}', tag), 'INVALID_IMAGE', '來源映像名稱無效。')
594
+ inspect_image(PYTHON_IMAGE)
595
+ base = PYTHON_IMAGE
596
+ with tempfile.TemporaryDirectory(prefix='fufu-asgi-') as tmp:
597
+ context = Path(tmp)
598
+ app = context / 'app'
599
+ app.mkdir()
600
+ # Validate before extraction to bound disk use and reject silent build omissions.
601
+ try:
602
+ with zipfile.ZipFile(archive) as z:
603
+ entries = z.infolist()
604
+ require(len(entries) <= 2000 and sum(i.file_size for i in entries) <= 32 * 1024**2,
605
+ 'SOURCE_LIMIT', 'ASGI 來源上限為 2000 個檔案、解壓縮後 32 MiB。')
606
+ require(all(not any(v.startswith('.') or v in {'__pycache__', 'node_modules'}
607
+ for v in PurePosixPath(i.filename).parts) for i in entries),
608
+ 'UNSUPPORTED_SOURCE', 'ASGI 來源包不可包含隱藏檔或開發快取。')
609
+ require(not {'Dockerfile', 'docker-compose.yml', 'compose.yaml', 'Procfile', 'pyproject.toml', 'setup.py'}.intersection(z.namelist()),
610
+ 'UNSUPPORTED_SOURCE', '來源部署使用固定 ASGI 配方;不接受自訂 Dockerfile、安裝或啟動設定。')
611
+ except (zipfile.BadZipFile, OSError):
612
+ raise FufuError('INVALID_ZIP', '來源必須是有效的 ZIP。') from None
613
+ extract_zip(Path(archive), app)
614
+ module = handler.split(':')[0].replace('.', '/')
615
+ require((app / (module + '.py')).is_file() or (app / module / '__init__.py').is_file(),
616
+ 'INVALID_HANDLER', '來源包缺少指定 ASGI 模組。')
617
+ require((app / 'uvicorn/__main__.py').is_file(), 'DEPENDENCIES_NOT_PREPARED', '請將 Uvicorn 與所有 Linux Python 3.12 相依套件打入來源包。')
618
+ requirements = app / 'requirements.txt'
619
+ require(not requirements.exists() or not requirements.read_text().strip(),
620
+ 'DEPENDENCIES_NOT_PREPARED', '來源模式不執行 requirements.txt;請先封裝相依套件。')
621
+ sdk = app / '_fufu_runtime_sdk.py'
622
+ require(not sdk.exists(), 'RESERVED_FILE', '來源與 FUFU SDK 保留檔名衝突。')
623
+ shutil.copyfile(Path(__file__).with_name('runtime_sdk.py'), sdk)
624
+ sdk.chmod(0o644)
625
+ command = ['python', '-m', 'uvicorn', handler, '--host', '0.0.0.0', '--port', str(port)]
626
+ (context / 'Dockerfile').write_text(
627
+ f'FROM {base}\nCOPY --chown=10001:10001 app/ /app/\nWORKDIR /app\n'
628
+ 'ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nUSER 10001:10001\n'
629
+ 'CMD ' + json.dumps(command) + '\n')
630
+ digest = build_image(context, tag, network='none')
631
+ policy = object_json(docker('image', 'inspect', digest, '--format',
632
+ '{"user":{{json .Config.User}},"size":{{json .Size}},"volumes":{{json .Config.Volumes}},"cmd":{{json .Config.Cmd}}}'))
633
+ require(policy.get('user') == '10001:10001' and type(policy.get('size')) is int
634
+ and 0 < policy['size'] <= 512 * 1024**2 and not policy.get('volumes') and policy.get('cmd') == command,
635
+ 'IMAGE_POLICY_REJECTED', '建置映像未通過執行身分、大小或啟動設定檢查。')
636
+ return digest
@@ -83,8 +83,16 @@ def parse(d):
83
83
  fields(d, {"apiVersion", "kind", "metadata", "spec"}, "root")
84
84
  require(d.get("apiVersion") == "serving.knative.dev/v1" and d.get("kind") == "Service",
85
85
  "UNSUPPORTED_CONFIG", "目前只支援 serving.knative.dev/v1 Service 的明列子集。")
86
- metadata = fields(d.get("metadata"), {"name"}, "metadata")
86
+ metadata = fields(d.get("metadata"), {"name", "annotations"}, "metadata")
87
87
  service = name(metadata.get("name"))
88
+ annotations = fields(metadata.get('annotations', {}), {'run.googleapis.com/ingress', 'run.googleapis.com/invoker-iam-disabled'}, 'metadata.annotations')
89
+ access = {}
90
+ if 'run.googleapis.com/ingress' in annotations:
91
+ require(annotations['run.googleapis.com/ingress'] in {'all', 'internal'}, 'UNSUPPORTED_CONFIG', 'Ingress 僅支援 all 或 FUFU 平台 internal。')
92
+ access['ingress'] = annotations['run.googleapis.com/ingress']
93
+ if 'run.googleapis.com/invoker-iam-disabled' in annotations:
94
+ require(annotations['run.googleapis.com/invoker-iam-disabled'] in {'true', 'false'}, 'UNSUPPORTED_CONFIG', 'Invoker IAM annotation 必須為字串 true 或 false。')
95
+ access['invokerIamDisabled'] = annotations['run.googleapis.com/invoker-iam-disabled'] == 'true'
88
96
  spec = fields(d.get("spec"), {"template"}, "spec")
89
97
  template = fields(spec.get("template"), {"spec"}, "spec.template")
90
98
  pod = fields(template.get("spec"), {"containers", 'serviceAccountName'}, "spec.template.spec")
@@ -111,10 +119,10 @@ def parse(d):
111
119
  require(isinstance(k, str) and k not in env, "INVALID_ENV", "環境變數名稱重複或無效。")
112
120
  env[k] = item.get("value")
113
121
  environment(env)
114
- return {"name": service, "image": image, "port": port, "env": env, **({'role': role} if role else {})}
122
+ return {"name": service, "image": image, "port": port, "env": env, **({'role': role} if role else {}), **({'publicAccess': access} if access else {})}
115
123
 
116
124
 
117
125
  def public_plan(spec):
118
126
  return {"backend": "local-docker", "kind": "cloud-run-http", "name": spec["name"],
119
127
  "port": spec["port"], "environment_names": sorted(spec["env"]),
120
- "checks": "strict-supported-subset", "image_resolution": "at-deploy"}
128
+ "checks": "strict-supported-subset", "image_resolution": "at-deploy", **({'publicAccess': spec['publicAccess']} if spec.get('publicAccess') else {})}
@@ -0,0 +1,171 @@
1
+ """Provider-shaped commands over the shared service access API."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ from .api_backend import ApiClient, api_selected, segment
8
+ from .errors import FufuError, require
9
+
10
+ AWS_ACTIONS = {'create-function-url-config', 'get-function-url-config', 'update-function-url-config',
11
+ 'delete-function-url-config', 'add-permission', 'remove-permission', 'get-policy'}
12
+ GCP_POLICY_ACTIONS = {'get-iam-policy', 'set-iam-policy', 'add-iam-policy-binding', 'remove-iam-policy-binding'}
13
+
14
+
15
+ def configure_manifest(spec, deployed=None):
16
+ config = spec.get('publicAccess', {})
17
+ args = SimpleNamespace(anonymous=None, ingress=config.get('ingress'),
18
+ invoker_iam=not config['invokerIamDisabled'] if 'invokerIamDisabled' in config else None)
19
+ configuration(args, 'cloud-run-http')
20
+ if deployed is None: return
21
+ return configure_gcp(args, 'cloud-run-http', spec['name'], deployed)
22
+
23
+
24
+ def access_path(kind, name):
25
+ return '/compute/services/' + segment(kind) + '/' + segment(name) + '/access'
26
+
27
+
28
+ def deployment_flags(parser):
29
+ group = parser.add_mutually_exclusive_group()
30
+ group.add_argument('--allow-unauthenticated', dest='anonymous', action='store_true')
31
+ group.add_argument('--no-allow-unauthenticated', dest='anonymous', action='store_false')
32
+ parser.set_defaults(anonymous=None)
33
+ parser.add_argument('--ingress', choices=['all', 'internal'])
34
+
35
+
36
+ def run_iam_flags(parser):
37
+ group = parser.add_mutually_exclusive_group()
38
+ group.add_argument('--invoker-iam-check', dest='invoker_iam', action='store_true')
39
+ group.add_argument('--no-invoker-iam-check', dest='invoker_iam', action='store_false')
40
+ parser.set_defaults(invoker_iam=None)
41
+
42
+
43
+ def configuration(args, kind):
44
+ anonymous, invoker = getattr(args, 'anonymous', None), getattr(args, 'invoker_iam', None)
45
+ require(anonymous is None or invoker is None, 'CONFLICTING_ARGUMENTS',
46
+ '請選擇 allUsers policy 或 Invoker IAM check 其中一種設定方式。')
47
+ config = {}
48
+ if getattr(args, 'ingress', None) is not None: config['ingress'] = args.ingress
49
+ if invoker is not None: config['invokerIamDisabled'] = not invoker
50
+ if config or anonymous is not None:
51
+ require(api_selected(), 'API_BACKEND_REQUIRED', '公開入口與 IAM 設定需要 FUFU API 模式;本機 Docker 不會假裝發布公網。')
52
+ return config
53
+
54
+
55
+ def configure_gcp(args, kind, name, deployed=None):
56
+ config = configuration(args, kind)
57
+ anonymous = getattr(args, 'anonymous', None)
58
+ if not config and anonymous is None:
59
+ return deployed
60
+ path = access_path(kind, name)
61
+ try:
62
+ with ApiClient('gcp') as api:
63
+ current = api.request('GET', path)
64
+ if anonymous is not None:
65
+ # One CAS update makes anonymous permission and ingress visible together.
66
+ config['allowUnauthenticated'] = anonymous
67
+ config['enabled'] = True
68
+ updated = api.request('PATCH', path, {**config, 'etag': current['etag']})
69
+ result = api.request('GET', path.removesuffix('/access'))
70
+ result['publicAccess'] = updated
71
+ return result
72
+ except FufuError as exc:
73
+ if deployed is not None:
74
+ raise FufuError('DEPLOYED_ACCESS_UPDATE_FAILED',
75
+ '程式部署已完成,但入口設定未完成;請 describe 確認目前狀態,再更新存取設定。') from exc
76
+ raise
77
+
78
+
79
+ def gcp_command(argv, Parser):
80
+ if argv[:2] == ['auth', 'print-identity-token']:
81
+ p = Parser(prog='gcloud-fufu auth print-identity-token', description='輸出 FUFU issuer、15 分鐘且綁定 audience 的 ID token。')
82
+ p.add_argument('--audiences', required=True)
83
+ a = p.parse_args(argv[2:])
84
+ with ApiClient('gcp') as api:
85
+ result = api.request('POST', '/compute/auth/identity-token', {'audience': a.audiences})
86
+ require(isinstance(result.get('token'), str), 'INVALID_RESPONSE', 'ID token 回應無效。')
87
+ sys.stdout.write(result['token'] + '\n')
88
+ return 0
89
+ run = argv[:2] == ['run', 'services']
90
+ args = argv[2:] if run else argv[1:]
91
+ action = args[0]
92
+ p = Parser(prog='gcloud-fufu ' + ('run services ' if run else 'functions ') + action)
93
+ p.add_argument('name')
94
+ if not run:
95
+ group = p.add_mutually_exclusive_group()
96
+ group.add_argument('--gen2', dest='gen2', action='store_true')
97
+ group.add_argument('--no-gen2', dest='gen2', action='store_false')
98
+ p.set_defaults(gen2=True)
99
+ if action == 'update':
100
+ deployment_flags(p); run_iam_flags(p)
101
+ elif action in {'add-iam-policy-binding', 'remove-iam-policy-binding'}:
102
+ p.add_argument('--member', required=True); p.add_argument('--role', required=True)
103
+ elif action == 'set-iam-policy':
104
+ p.add_argument('policy_file')
105
+ a = p.parse_args(args[1:])
106
+ kind = 'cloud-run-http' if run else 'cloud-run-functions-http' if a.gen2 else 'cloud-functions-gen1-http'
107
+ if action == 'update':
108
+ require(configuration(a, kind) or a.anonymous is not None, 'MISSING_CONFIGURATION', '至少提供一個存取設定旗標。')
109
+ return configure_gcp(a, kind, a.name)
110
+ path = access_path(kind, a.name)
111
+ with ApiClient('gcp', read_only=action == 'get-iam-policy') as api:
112
+ current = api.request('GET', path)
113
+ if action == 'set-iam-policy':
114
+ file = Path(a.policy_file)
115
+ require(file.is_file() and file.stat().st_size <= 65536, 'INVALID_POLICY', 'Policy JSON 上限為 64 KiB。')
116
+ policy = json.loads(file.read_text())
117
+ require(isinstance(policy, dict) and not (policy.keys() - {'bindings', 'etag', 'version'})
118
+ and policy.get('version', 1) == 1, 'UNSUPPORTED_POLICY', '僅支援無條件 Invoker bindings;其他欄位不會被忽略。')
119
+ current = api.request('POST', path + '/policy', {'operation': 'set-bindings', 'bindings': policy.get('bindings', []),
120
+ 'etag': policy.get('etag', current['etag'])})
121
+ elif action != 'get-iam-policy':
122
+ current = api.request('POST', path + '/policy', {'operation': 'add-binding' if action.startswith('add-') else 'remove-binding',
123
+ 'role': a.role, 'member': a.member, 'etag': current['etag']})
124
+ return {'version': 1, 'bindings': current['bindings'], 'etag': current['etag'], 'Fufu': metadata(current)}
125
+
126
+
127
+ def metadata(access):
128
+ return {'mode': 'simplified', 'visibility': access['visibility'], 'authenticationScheme': access['authenticationScheme'],
129
+ 'limits': access['limits'], 'serviceId': access['serviceId']}
130
+
131
+
132
+ def aws_command(argv, Parser):
133
+ action = argv[1]
134
+ p = Parser(prog='aws-fufu lambda ' + action, description='Function URL BUFFERED 子集;AWS_IAM 支援長效 FUFU access key 的 SigV4 header signing。')
135
+ p.add_argument('--function-name', required=True)
136
+ if action in {'create-function-url-config', 'update-function-url-config'}:
137
+ p.add_argument('--auth-type', choices=['NONE', 'AWS_IAM'], required=action.startswith('create-'))
138
+ p.add_argument('--invoke-mode', choices=['BUFFERED'], default='BUFFERED')
139
+ if action in {'add-permission', 'remove-permission'}: p.add_argument('--statement-id', required=True)
140
+ if action == 'add-permission':
141
+ p.add_argument('--action', dest='permission_action', required=True)
142
+ p.add_argument('--principal', required=True)
143
+ p.add_argument('--function-url-auth-type', choices=['NONE', 'AWS_IAM'])
144
+ p.add_argument('--invoked-via-function-url', action='store_true')
145
+ p.add_argument('--revision-id')
146
+ a = p.parse_args(argv[2:])
147
+ path = access_path('aws-lambda-sync', a.function_name)
148
+ with ApiClient('aws', read_only=action.startswith('get-')) as api:
149
+ access = api.request('GET', path)
150
+ etag = a.revision_id or access['etag']
151
+ if action in {'create-function-url-config', 'update-function-url-config', 'delete-function-url-config'}:
152
+ body = {'operation': action.split('-')[0], 'etag': etag}
153
+ if getattr(a, 'auth_type', None) is not None: body['authType'] = a.auth_type
154
+ access = api.request('PATCH', path, body)
155
+ if action.startswith('delete-'): return {}
156
+ if action == 'get-function-url-config':
157
+ require(access['enabled'], 'RESOURCE_NOT_FOUND', 'Function URL 尚未建立或已刪除。')
158
+ if action in {'add-permission', 'remove-permission'}:
159
+ body = {'operation': action, 'statementId': a.statement_id, 'etag': etag}
160
+ if action == 'add-permission':
161
+ body.update(action=a.permission_action, principal=a.principal)
162
+ if a.function_url_auth_type is not None: body['functionUrlAuthType'] = a.function_url_auth_type
163
+ if a.invoked_via_function_url: body['invokedViaFunctionUrl'] = True
164
+ access = api.request('POST', path + '/policy', body)
165
+ return {'Statement': json.dumps(next(s for s in access['statements'] if s['Sid'] == a.statement_id)),
166
+ 'Fufu': metadata(access)} if action == 'add-permission' else {}
167
+ if action == 'get-policy':
168
+ return {'Policy': json.dumps({'Version': '2012-10-17', 'Statement': access['statements']}), 'RevisionId': access['etag'], 'Fufu': metadata(access)}
169
+ who = api.whoami()
170
+ return {'FunctionUrl': access['url'], 'FunctionArn': f"arn:aws:lambda:{who['region']}:{who['accountId']}:function:{a.function_name}",
171
+ 'AuthType': access['authType'], 'InvokeMode': 'BUFFERED', 'LastModifiedTime': access['updatedAt'], 'Fufu': metadata(access)}
@@ -43,6 +43,33 @@ def lambda_archive(path):
43
43
  return base64.b64encode(p.read_bytes()).decode()
44
44
 
45
45
 
46
+ def cloud_run_archive(source):
47
+ """Upload an explicitly prepared ZIP or directory, without dependency execution."""
48
+ root = Path(source)
49
+ require(not root.is_symlink(), 'INVALID_SOURCE', '來源不可為符號連結。')
50
+ if root.is_file():
51
+ return lambda_archive(root)
52
+ require(root.is_dir(), 'INVALID_SOURCE', '請指定已封裝相依套件的 ASGI ZIP 或目錄。')
53
+ data = io.BytesIO()
54
+ total = count = 0
55
+ with zipfile.ZipFile(data, 'w', zipfile.ZIP_DEFLATED) as archive:
56
+ for path in sorted(root.rglob('*')):
57
+ relative = path.relative_to(root)
58
+ require(not path.is_symlink(), 'INVALID_SOURCE', '來源不可含符號連結。')
59
+ require(not any(v.startswith('.') or v in {'__pycache__', 'node_modules'} for v in relative.parts),
60
+ 'UNSUPPORTED_SOURCE', '請使用只含應用及相依套件的目錄,不可含隱藏檔或快取。')
61
+ if path.is_dir():
62
+ continue
63
+ require(path.is_file(), 'INVALID_SOURCE', '來源只接受一般檔案。')
64
+ total += path.stat().st_size
65
+ count += 1
66
+ require(count <= 2000 and total <= 32 * 1024**2, 'SOURCE_LIMIT', 'ASGI 來源超過上限。')
67
+ archive.write(path, relative.as_posix())
68
+ require(data.tell() <= MAX_ARCHIVE, 'SOURCE_LIMIT', '遠端 ASGI ZIP 上限為 6 MiB。')
69
+ require(0 < data.tell() <= MAX_ARCHIVE, 'SOURCE_LIMIT', '遠端 ASGI ZIP 上限為 6 MiB。')
70
+ return base64.b64encode(data.getvalue()).decode()
71
+
72
+
46
73
  class RemoteBackend:
47
74
  def __init__(self, kind):
48
75
  require(kind in KIND_PROVIDER, "CAPABILITY_NOT_SUPPORTED", "此類型尚無遠端 compute。")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -69,7 +69,8 @@ credential 內含 FUFU endpoint。`FUFU_ENDPOINT` 可明確覆寫目的地:僅
69
69
  一般命令交換短期權杖並在結束時嘗試撤銷。`fufu auth assume-role`
70
70
  可建立受限 session 檔,檔案以排他方式建立且權限為 `0600`;請勿提交。
71
71
  FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制存取。
72
- 原生雲端的 GitHub auth actions、OIDC、SigV4 和 OAuth 不直接相容。
72
+ 原生雲端的 GitHub auth actions、OIDC 和 OAuth 不直接相容。
73
+ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header signing;管理 API 仍使用 FUFU token。
73
74
 
74
75
  ## 部署與支援範圍
75
76
 
@@ -77,21 +78,32 @@ FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制
77
78
  fufu capabilities
78
79
  fufu capabilities --remote --provider gcp --require cloud-run-http
79
80
  gcloud-fufu run deploy example --image approved-image:tag
81
+ gcloud-fufu run deploy example --source ./prepared-asgi.zip --entry-point main:app
80
82
  gcloud-fufu functions deploy example-function --gen2 \
81
83
  --runtime python312 --source ./function --entry-point handler --trigger-http
82
84
  aws-fufu lambda list-functions
83
85
  ```
84
86
 
85
- 映像需先由 FUFU 管理者準備及核准。Function 來源會上傳指定目錄中的
87
+ 自訂映像需先由 FUFU 管理者準備及核准。API 模式的 Cloud Run `--source`
88
+ 接受已封裝 Uvicorn 與相依套件的 Python 3.12 ASGI ZIP/乾淨目錄,自動離線建置、
89
+ 檢查映像並核准到目前租戶;不需要主機 SSH 或每次人工核准。
90
+ `--entry-point module:app` 是 FUFU 擴充旗標,預設 `main:app`。
91
+ 來源上限為 ZIP 6 MiB、解壓縮 32 MiB、2000 個檔案;不接受隱藏檔、Dockerfile、
92
+ 自訂安裝設定或非空 requirements.txt。相依套件需在 CI 封裝,服務端不執行安裝 hook。
93
+ 這是固定 ASGI 配方,不提供完整 Cloud Build 或 registry API。
94
+ 成功回應包含 `build.status`、`imageDigest` 與來源包雜湊;自動核准映像
95
+ 僅供該租戶目前服務使用,取代或刪除最後一個引用後不再開放重用。
96
+
97
+ Function 來源會上傳指定目錄中的
86
98
  Python 與 requirements 檔;Lambda 會上傳指定的 ZIP。請使用只含必要程式碼的
87
99
  目錄或 ZIP,先移除硬編碼秘密、個人資料與不需要的檔案。
88
100
  CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
89
101
 
90
102
  | 服務 | 可用範圍與主要限制 |
91
103
  | --- | --- |
92
- | Cloud Run | 單一已核准 HTTP 容器;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
104
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
93
105
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
94
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新;遠端 ZIP 上限 6 MiB |
106
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;遠端 ZIP 上限 6 MiB |
95
107
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
96
108
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
97
109
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -107,11 +119,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
107
119
 
108
120
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
109
121
  `metadata.name`、單一 container 的 image / port / env,以及明列的 service account。
122
+ 服務 metadata.annotations 支援 `run.googleapis.com/ingress`(all/internal)與
123
+ `run.googleapis.com/invoker-iam-disabled`(字串 true/false)。
110
124
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
111
125
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
112
126
 
113
127
  ## 隱私與執行邊界
114
128
 
129
+ 0.7.0 新增服務公開存取。需由平台配置 DNS、HTTPS 入口與 WAF 放行,
130
+ `publicAccess.status=ready` 表示入口已配置;仍應由 CI 實際 HTTP 呼叫確認外部可達。
131
+
132
+ ```bash
133
+ gcloud-fufu run services update example --allow-unauthenticated --ingress all
134
+ gcloud-fufu run services get-iam-policy example
135
+ gcloud-fufu run services update example --no-allow-unauthenticated
136
+ gcloud-fufu auth print-identity-token --audiences https://app-SERVICE_ID.example.com
137
+ aws-fufu lambda create-function-url-config --function-name example --auth-type AWS_IAM
138
+ aws-fufu lambda get-function-url-config --function-name example
139
+ ```
140
+
141
+ UI 與 CLI 共用同一份服務政策。Cloud Run 與 HTTP Functions 的匿名政策使用
142
+ `allUsers` 與對應 Invoker 角色。Lambda `NONE` 必須另加 `lambda:InvokeFunctionUrl`
143
+ (`--function-url-auth-type NONE`)和 `lambda:InvokeFunction`
144
+ (`--invoked-via-function-url`)兩項資源權限;只設定 NONE 不會自動開放呼叫。
145
+ 入口支援有界限的普通 HTTP:請求與回應各 6 MiB、執行 15 秒,每服務每分鐘 120 次。
146
+ 不支援 SSE、WebSocket、串流、STS 暫時金鑰、presigned URL 或真實 VPC。
147
+ GCP ID token 由 FUFU 的 RS256 issuer 簽發,綁定精確 audience,15 分鐘後過期;不接受 Google ID token。
148
+ `print-identity-token` 明確輸出秘密,應直接放入受控變數,避免 CI 日誌或 shell tracing。
149
+
115
150
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
116
151
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
117
152
  - `whoami` 會輸出目前租戶、provider 與 scope;查詢/invoke 會輸出所要求的資料。
@@ -13,6 +13,7 @@ src/fufu_cloud_cli/docker_backend.py
13
13
  src/fufu_cloud_cli/errors.py
14
14
  src/fufu_cloud_cli/lambda_adapter.py
15
15
  src/fufu_cloud_cli/manifest.py
16
+ src/fufu_cloud_cli/public_access.py
16
17
  src/fufu_cloud_cli/remote_compute.py
17
18
  src/fufu_cloud_cli/runtime_sdk.py
18
19
  src/fufu_cloud_cli/serverless.py
File without changes
File without changes