fufu-cloud-cli 0.6.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.6.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.7.0}/PKG-INFO +28 -4
  2. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/README.md +27 -3
  3. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/capabilities.json +8 -1
  6. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cli.py +29 -10
  7. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/docker_backend.py +9 -3
  8. {fufu_cloud_cli-0.6.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.6.0 → fufu_cloud_cli-0.7.0/src/fufu_cloud_cli.egg-info}/PKG-INFO +28 -4
  11. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
  12. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/LICENSE +0 -0
  13. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/MANIFEST.in +0 -0
  14. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/setup.cfg +0 -0
  15. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/__main__.py +0 -0
  16. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/api_backend.py +0 -0
  17. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
  18. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/control.py +0 -0
  19. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/errors.py +0 -0
  20. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  21. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/remote_compute.py +0 -0
  22. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  23. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  24. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  25. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  26. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli/serverless.py +0 -0
  27. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  28. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  29. {fufu_cloud_cli-0.6.0 → fufu_cloud_cli-0.7.0}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  30. {fufu_cloud_cli-0.6.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.6.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
 
@@ -100,9 +101,9 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
100
101
 
101
102
  | 服務 | 可用範圍與主要限制 |
102
103
  | --- | --- |
103
- | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
104
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
104
105
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
105
- | 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 |
106
107
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
107
108
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
108
109
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -118,11 +119,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
118
119
 
119
120
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
120
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)。
121
124
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
122
125
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
123
126
 
124
127
  ## 隱私與執行邊界
125
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
+
126
150
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
127
151
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
128
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
 
@@ -87,9 +88,9 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
87
88
 
88
89
  | 服務 | 可用範圍與主要限制 |
89
90
  | --- | --- |
90
- | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
91
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
91
92
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
92
- | 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 |
93
94
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
94
95
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
95
96
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -105,11 +106,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
105
106
 
106
107
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
107
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)。
108
111
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
109
112
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
110
113
 
111
114
  ## 隱私與執行邊界
112
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
+
113
137
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
114
138
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
115
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.6.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.6.0"
3
+ __version__ = "0.7.0"
@@ -30,7 +30,7 @@
30
30
  "no-autoscaling",
31
31
  "no-traffic-splitting",
32
32
  "no-jobs",
33
- "no-public-ingress"
33
+ "public-ingress-requires-configured-api-gateway"
34
34
  ],
35
35
  "backends": [
36
36
  "local-docker",
@@ -82,6 +82,13 @@
82
82
  "api"
83
83
  ]
84
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
+ },
85
92
  "serverless-framework": {
86
93
  "status": "unsupported",
87
94
  "reason": "未實作原生 Framework provider/plugins/CloudFormation;可改用明確的 serverless-profile-adapter 子集。"
@@ -13,6 +13,7 @@ from .docker_backend import (Backend, FUNCTIONS_IMAGE, INGRESS_IMAGE, KINDS, LAM
13
13
  from .errors import FufuError, require
14
14
  from .api_backend import ApiClient, api_selected, remote_command
15
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()
@@ -211,6 +213,7 @@ def gcloud_command(argv):
211
213
  d.add_argument("--port", type=int, default=8080)
212
214
  d.add_argument('--service-account')
213
215
  deployment_options(d)
216
+ deployment_flags(d); run_iam_flags(d)
214
217
  services = run_sub.add_parser("services").add_subparsers(dest="service_action", required=True)
215
218
  replace = services.add_parser("replace")
216
219
  replace.add_argument("file")
@@ -228,6 +231,7 @@ def gcloud_command(argv):
228
231
  generation_options(d)
229
232
  d.add_argument("--trigger-http", required=True, action="store_true")
230
233
  deployment_options(d)
234
+ deployment_flags(d)
231
235
  for action in ("describe", "delete", "call"):
232
236
  q = fn.add_parser(action)
233
237
  q.add_argument("name")
@@ -240,25 +244,28 @@ def gcloud_command(argv):
240
244
  action = a.service_action if a.action == "services" else a.action
241
245
  if action == "replace":
242
246
  spec = manifest.load(a.file)
247
+ configure_manifest(spec)
243
248
  spec["env"].update(env_from_names(a.env))
244
- return compute_backend(kind).deploy(kind=kind, service=spec["name"], image=spec["image"], port=spec["port"],
245
- 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 {})))
246
251
  if action == "deploy":
247
252
  manifest.name(a.name)
253
+ configuration(a, kind)
254
+ finish = lambda deployed: configure_gcp(a, kind, a.name, deployed)
248
255
  env = env_from_names(a.env)
249
256
  if a.service == 'run' and a.source:
250
257
  require(api_selected(), 'API_BACKEND_REQUIRED', 'Cloud Run --source 自動核准需要 FUFU API 模式。')
251
- return RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env, port=a.port,
258
+ return finish(RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env, port=a.port,
252
259
  archive=cloud_run_archive(a.source), runtime='python312', handler=a.entry_point,
253
- startup_timeout=a.startup_timeout, role=a.service_account)
260
+ startup_timeout=a.startup_timeout, role=a.service_account))
254
261
  if a.service == 'run':
255
262
  require(a.entry_point == 'main:app', 'UNSUPPORTED_ARGUMENT', '--entry-point 只用於 --source。')
256
263
  if a.service == 'functions' and api_selected():
257
- return RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env,
258
- 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))
259
266
  image = a.image if a.service == "run" else build_function(a.source, a.entry_point)
260
- return compute_backend(kind).deploy(kind=kind, service=a.name, image=image, port=getattr(a, "port", 8080),
261
- 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 {})))
262
269
  b = compute_backend(kind)
263
270
  if action == "describe":
264
271
  return b.describe(kind, a.name)
@@ -371,6 +378,18 @@ def entry(provider, argv=None):
371
378
  require('=' not in argv[i], 'UNSUPPORTED_ARGUMENT', '布林旗標不接受額外值。'); i += 1
372
379
  else: rest.append(argv[i]); i += 1
373
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
374
393
  if provider == 'aws-fufu' and argv and argv[0] == 'serverless':
375
394
  from .serverless import command
376
395
  emit(command(argv[1:], Parser)); return
@@ -228,9 +228,13 @@ 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",
237
+ "revision": r["revision"], "serviceId": self.service_id(r), "url": self.url(r), "readiness": "tcp",
234
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
 
@@ -298,7 +302,7 @@ 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 {}),
@@ -423,12 +427,14 @@ class Backend:
423
427
  docker("network", "rm", network)
424
428
  return {"workspace": self.workspace, "removed_containers": len(ids)}
425
429
 
426
- def invoke(self, kind, service, payload, *, path="/", timeout=15):
430
+ def invoke(self, kind, service, payload, *, path="/", timeout=15, expected_service_id=None):
427
431
  require(1 <= timeout <= 120, "INVALID_TIMEOUT", "請求期限須介於 1–120 秒。")
428
432
  require(len(payload) <= MAX_PAYLOAD, "PAYLOAD_LIMIT", "Payload 超過 6 MiB。")
429
433
  require(isinstance(path, str) and path.startswith("/") and not path.startswith("//")
430
434
  and "\r" not in path and "\n" not in path, "INVALID_PATH", "只能使用本機服務相對路徑。")
431
435
  data = self.describe(kind, service)
436
+ require(expected_service_id is None or data.get('serviceId') == expected_service_id,
437
+ 'NOT_FOUND', '應用網址已失效。')
432
438
  route = "/2015-03-31/functions/function/invocations" if kind == "aws-lambda-sync" else path
433
439
 
434
440
  class NoRedirect(request.HTTPRedirectHandler):
@@ -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)}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.6.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
 
@@ -100,9 +101,9 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
100
101
 
101
102
  | 服務 | 可用範圍與主要限制 |
102
103
  | --- | --- |
103
- | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
104
+ | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
104
105
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
105
- | 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 |
106
107
  | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
107
108
  | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
108
109
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
@@ -118,11 +119,34 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
118
119
 
119
120
  Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
120
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)。
121
124
  `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
122
125
  相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
123
126
 
124
127
  ## 隱私與執行邊界
125
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
+
126
150
  - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
127
151
  - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
128
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