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.
- {fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.7.0}/PKG-INFO +40 -5
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/README.md +39 -4
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/pyproject.toml +1 -1
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/capabilities.json +13 -1
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cli.py +39 -10
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/docker_backend.py +64 -6
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/manifest.py +11 -3
- fufu_cloud_cli-0.7.0/src/fufu_cloud_cli/public_access.py +171 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/remote_compute.py +27 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0/src/fufu_cloud_cli.egg-info}/PKG-INFO +40 -5
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/LICENSE +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/setup.cfg +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/api_backend.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/control.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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 會輸出所要求的資料。
|
|
@@ -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
|
-
"
|
|
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.
|
|
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
|
-
**({
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.5.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|