fufu-cloud-cli 0.5.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 (29) hide show
  1. fufu_cloud_cli-0.5.0/LICENSE +6 -0
  2. fufu_cloud_cli-0.5.0/MANIFEST.in +2 -0
  3. fufu_cloud_cli-0.5.0/PKG-INFO +125 -0
  4. fufu_cloud_cli-0.5.0/README.md +112 -0
  5. fufu_cloud_cli-0.5.0/pyproject.toml +28 -0
  6. fufu_cloud_cli-0.5.0/setup.cfg +4 -0
  7. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/__init__.py +3 -0
  8. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/__main__.py +9 -0
  9. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/api_backend.py +302 -0
  10. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/capabilities.json +570 -0
  11. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/cli.py +421 -0
  12. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/cloud_data.py +99 -0
  13. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/control.py +185 -0
  14. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/docker_backend.py +578 -0
  15. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/errors.py +11 -0
  16. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/lambda_adapter.py +30 -0
  17. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/manifest.py +120 -0
  18. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/remote_compute.py +93 -0
  19. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/runtime/functions/Dockerfile +6 -0
  20. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/runtime/ingress/Dockerfile +6 -0
  21. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/runtime/ingress/proxy.py +49 -0
  22. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/runtime_sdk.py +114 -0
  23. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli/serverless.py +214 -0
  24. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/PKG-INFO +125 -0
  25. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/SOURCES.txt +27 -0
  26. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/dependency_links.txt +1 -0
  27. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/entry_points.txt +5 -0
  28. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/requires.txt +4 -0
  29. fufu_cloud_cli-0.5.0/src/fufu_cloud_cli.egg-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ All rights reserved.
2
+
3
+ This distribution is not offered under an open-source license. No permission
4
+ to redistribute, modify, or sublicense the FUFU Cloud CLI source is granted
5
+ by publication of this package. Third-party components retain their own
6
+ licenses, as stated in THIRD_PARTY_NOTICES.md when bundled.
@@ -0,0 +1,2 @@
1
+ include README.md LICENSE
2
+ recursive-include src *.py *.json Dockerfile
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: fufu-cloud-cli
3
+ Version: 0.5.0
4
+ Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
5
+ License-Expression: LicenseRef-Proprietary
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: PyYAML==6.0.3
10
+ Provides-Extra: yaml
11
+ Requires-Dist: PyYAML==6.0.3; extra == "yaml"
12
+ Dynamic: license-file
13
+
14
+ # FUFU Cloud CLI
15
+
16
+ 以四個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
17
+ `gcloud-fufu`、`az-fufu`。這是 AWS、Google Cloud 與 Azure 的**明確子集**,
18
+ 只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
19
+
20
+ ## 安裝
21
+
22
+ 需要 Python 3.10 以上。Python 套件預設包含 YAML 相依套件:
23
+
24
+ ```bash
25
+ pip install fufu-cloud-cli
26
+ fufu --version
27
+ ```
28
+
29
+ npm 版本需要 Node.js 18 以上,以及已安裝的 Python 3.10 以上;四個入口
30
+ 呼叫相同的 Python CLI。npm 包內附純 Python YAML parser,沒有安裝腳本,
31
+ 不會自行下載 Python 或執行 pip。
32
+
33
+ ```bash
34
+ npm install fufu-cloud-cli
35
+ npx --no-install fufu --version
36
+ npx --no-install aws-fufu --help
37
+ ```
38
+
39
+ 全域安裝可使用 `npm install -g fufu-cloud-cli`。預設 Python 指令為 `python3`;
40
+ 若名稱不同,將 `FUFU_PYTHON` 設成可信 Python 執行檔的路徑。
41
+ 本機 Docker backend 只支援 Linux 與本機 Unix socket。API backend
42
+ 不需要安裝 Docker;其他作業系統仍須另外驗證其 Python 與網路環境。
43
+
44
+ ## 憑證與 CI
45
+
46
+ 向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
47
+ CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
48
+ Cookie 或正式雲端登入工作階段。
49
+
50
+ 在 CI 的秘密管理功能中注入 credential 檔案,並設定:
51
+
52
+ ```bash
53
+ export FUFU_BACKEND=api
54
+ export FUFU_AWS_CREDENTIAL_FILE=/run/secrets/fufu-aws.json
55
+ export FUFU_GCP_CREDENTIAL_FILE=/run/secrets/fufu-gcp.json
56
+ export FUFU_AZURE_CREDENTIAL_FILE=/run/secrets/fufu-azure.json
57
+
58
+ fufu auth whoami --provider aws
59
+ aws-fufu sts get-caller-identity
60
+ gcloud-fufu auth list
61
+ az-fufu account show
62
+ ```
63
+
64
+ credential 內含 FUFU endpoint。`FUFU_ENDPOINT` 可明確覆寫目的地:僅限
65
+ 你信任且獲授權接收 credential 的 FUFU 服務。正式網路需使用 HTTPS;
66
+ 只有 loopback 允許 HTTP。TLS 憑證驗證保持開啟,不跟隨重新導向,
67
+ 不繼承系統 HTTP proxy。
68
+
69
+ 一般命令交換短期權杖並在結束時嘗試撤銷。`fufu auth assume-role`
70
+ 可建立受限 session 檔,檔案以排他方式建立且權限為 `0600`;請勿提交。
71
+ FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制存取。
72
+ 原生雲端的 GitHub auth actions、OIDC、SigV4 和 OAuth 不直接相容。
73
+
74
+ ## 部署與支援範圍
75
+
76
+ ```bash
77
+ fufu capabilities
78
+ fufu capabilities --remote --provider gcp --require cloud-run-http
79
+ gcloud-fufu run deploy example --image approved-image:tag
80
+ gcloud-fufu functions deploy example-function --gen2 \
81
+ --runtime python312 --source ./function --entry-point handler --trigger-http
82
+ aws-fufu lambda list-functions
83
+ ```
84
+
85
+ 映像需先由 FUFU 管理者準備及核准。Function 來源會上傳指定目錄中的
86
+ Python 與 requirements 檔;Lambda 會上傳指定的 ZIP。請使用只含必要程式碼的
87
+ 目錄或 ZIP,先移除硬編碼秘密、個人資料與不需要的檔案。
88
+ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
89
+
90
+ | 服務 | 可用範圍與主要限制 |
91
+ | --- | --- |
92
+ | Cloud Run | 單一已核准 HTTP 容器;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
93
+ | 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 |
95
+ | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
96
+ | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
97
+ | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
98
+ | API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
99
+ | Azure | account 與資源群組目錄;尚無 Azure Functions 或其他 Azure runtime |
100
+ | EC2 / ECS / App Engine 等 | 部分只有 mock 目錄;不能視為執行中的 VM、Task 或應用 |
101
+
102
+ `fufu capabilities` 是隨版支援清單;`--remote` 另外查詢服務端能力。
103
+ `implemented` 代表明列子集已接入,`partial`、`mock-only`、`unsupported`
104
+ 均不代表完整服務可用。未支援命令、旗標、YAML 欄位會拒絕,CI 應檢查退出碼。
105
+
106
+ ## YAML / JSON
107
+
108
+ Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
109
+ `metadata.name`、單一 container 的 image / port / env,以及明列的 service account。
110
+ `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
111
+ 相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
112
+
113
+ ## 隱私與執行邊界
114
+
115
+ - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
116
+ - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
117
+ - `whoami` 會輸出目前租戶、provider 與 scope;查詢/invoke 會輸出所要求的資料。
118
+ 請將 CI 日誌視為可能包含應用資料,不把敏感查詢結果公開。
119
+ - 本機部署會操作 Docker。明確執行 runtime prepare 或 function build 時,
120
+ 可能下載固定映像或安裝應用 requirements;不可信 requirements 仍可執行建置程式。
121
+ - FUFU 服務端的租戶隔離、IAM、日誌保存與資料保護由服務端負責。
122
+ CLI 的本機檢查不等於對任何遠端部署作永久安全保證。
123
+
124
+ 本套件目前保留著作權,未授予開源授權。套件公開可下載與原始碼採用開源授權
125
+ 是不同事項;第三方 YAML parser 依其 MIT 授權散布。
@@ -0,0 +1,112 @@
1
+ # FUFU Cloud CLI
2
+
3
+ 以四個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
4
+ `gcloud-fufu`、`az-fufu`。這是 AWS、Google Cloud 與 Azure 的**明確子集**,
5
+ 只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
6
+
7
+ ## 安裝
8
+
9
+ 需要 Python 3.10 以上。Python 套件預設包含 YAML 相依套件:
10
+
11
+ ```bash
12
+ pip install fufu-cloud-cli
13
+ fufu --version
14
+ ```
15
+
16
+ npm 版本需要 Node.js 18 以上,以及已安裝的 Python 3.10 以上;四個入口
17
+ 呼叫相同的 Python CLI。npm 包內附純 Python YAML parser,沒有安裝腳本,
18
+ 不會自行下載 Python 或執行 pip。
19
+
20
+ ```bash
21
+ npm install fufu-cloud-cli
22
+ npx --no-install fufu --version
23
+ npx --no-install aws-fufu --help
24
+ ```
25
+
26
+ 全域安裝可使用 `npm install -g fufu-cloud-cli`。預設 Python 指令為 `python3`;
27
+ 若名稱不同,將 `FUFU_PYTHON` 設成可信 Python 執行檔的路徑。
28
+ 本機 Docker backend 只支援 Linux 與本機 Unix socket。API backend
29
+ 不需要安裝 Docker;其他作業系統仍須另外驗證其 Python 與網路環境。
30
+
31
+ ## 憑證與 CI
32
+
33
+ 向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
34
+ CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
35
+ Cookie 或正式雲端登入工作階段。
36
+
37
+ 在 CI 的秘密管理功能中注入 credential 檔案,並設定:
38
+
39
+ ```bash
40
+ export FUFU_BACKEND=api
41
+ export FUFU_AWS_CREDENTIAL_FILE=/run/secrets/fufu-aws.json
42
+ export FUFU_GCP_CREDENTIAL_FILE=/run/secrets/fufu-gcp.json
43
+ export FUFU_AZURE_CREDENTIAL_FILE=/run/secrets/fufu-azure.json
44
+
45
+ fufu auth whoami --provider aws
46
+ aws-fufu sts get-caller-identity
47
+ gcloud-fufu auth list
48
+ az-fufu account show
49
+ ```
50
+
51
+ credential 內含 FUFU endpoint。`FUFU_ENDPOINT` 可明確覆寫目的地:僅限
52
+ 你信任且獲授權接收 credential 的 FUFU 服務。正式網路需使用 HTTPS;
53
+ 只有 loopback 允許 HTTP。TLS 憑證驗證保持開啟,不跟隨重新導向,
54
+ 不繼承系統 HTTP proxy。
55
+
56
+ 一般命令交換短期權杖並在結束時嘗試撤銷。`fufu auth assume-role`
57
+ 可建立受限 session 檔,檔案以排他方式建立且權限為 `0600`;請勿提交。
58
+ FUFU credential 與 session 檔仍屬秘密,必須由作業系統和 CI 控制存取。
59
+ 原生雲端的 GitHub auth actions、OIDC、SigV4 和 OAuth 不直接相容。
60
+
61
+ ## 部署與支援範圍
62
+
63
+ ```bash
64
+ fufu capabilities
65
+ fufu capabilities --remote --provider gcp --require cloud-run-http
66
+ gcloud-fufu run deploy example --image approved-image:tag
67
+ gcloud-fufu functions deploy example-function --gen2 \
68
+ --runtime python312 --source ./function --entry-point handler --trigger-http
69
+ aws-fufu lambda list-functions
70
+ ```
71
+
72
+ 映像需先由 FUFU 管理者準備及核准。Function 來源會上傳指定目錄中的
73
+ Python 與 requirements 檔;Lambda 會上傳指定的 ZIP。請使用只含必要程式碼的
74
+ 目錄或 ZIP,先移除硬編碼秘密、個人資料與不需要的檔案。
75
+ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
76
+
77
+ | 服務 | 可用範圍與主要限制 |
78
+ | --- | --- |
79
+ | Cloud Run | 單一已核准 HTTP 容器;無 autoscaling、traffic splitting、Jobs 或公開 ingress |
80
+ | 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 |
82
+ | GCS / S3 | bucket 與 object 操作子集;單一 object 上限 6 MiB,無 versioning / signed URL |
83
+ | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
84
+ | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
85
+ | API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
86
+ | Azure | account 與資源群組目錄;尚無 Azure Functions 或其他 Azure runtime |
87
+ | EC2 / ECS / App Engine 等 | 部分只有 mock 目錄;不能視為執行中的 VM、Task 或應用 |
88
+
89
+ `fufu capabilities` 是隨版支援清單;`--remote` 另外查詢服務端能力。
90
+ `implemented` 代表明列子集已接入,`partial`、`mock-only`、`unsupported`
91
+ 均不代表完整服務可用。未支援命令、旗標、YAML 欄位會拒絕,CI 應檢查退出碼。
92
+
93
+ ## YAML / JSON
94
+
95
+ Cloud Run manifest 支援 `serving.knative.dev/v1`、`kind: Service`、
96
+ `metadata.name`、單一 container 的 image / port / env,以及明列的 service account。
97
+ `fufu plan --file service.yaml` 可在不部署的情況下檢查設定;plan 僅列出環境變數名稱。
98
+ 相同設定可用 JSON。其他正式雲端欄位需要顯式調整,不能宣稱所有設定無痛相容。
99
+
100
+ ## 隱私與執行邊界
101
+
102
+ - 套件沒有遙測、廣告、背景更新或安裝時網路腳本。
103
+ - API 請求只使用明確提供的 FUFU endpoint 和 credential;不掃描使用者家目錄。
104
+ - `whoami` 會輸出目前租戶、provider 與 scope;查詢/invoke 會輸出所要求的資料。
105
+ 請將 CI 日誌視為可能包含應用資料,不把敏感查詢結果公開。
106
+ - 本機部署會操作 Docker。明確執行 runtime prepare 或 function build 時,
107
+ 可能下載固定映像或安裝應用 requirements;不可信 requirements 仍可執行建置程式。
108
+ - FUFU 服務端的租戶隔離、IAM、日誌保存與資料保護由服務端負責。
109
+ CLI 的本機檢查不等於對任何遠端部署作永久安全保證。
110
+
111
+ 本套件目前保留著作權,未授予開源授權。套件公開可下載與原始碼採用開源授權
112
+ 是不同事項;第三方 YAML parser 依其 MIT 授權散布。
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77,<81"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fufu-cloud-cli"
7
+ version = "0.5.0"
8
+ description = "FUFU Cloud CLI for tenant-scoped mock cloud development and deployment"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["PyYAML==6.0.3"]
12
+ license = "LicenseRef-Proprietary"
13
+ license-files = ["LICENSE"]
14
+
15
+ [project.optional-dependencies]
16
+ yaml = ["PyYAML==6.0.3"]
17
+
18
+ [project.scripts]
19
+ fufu = "fufu_cloud_cli.cli:main"
20
+ aws-fufu = "fufu_cloud_cli.cli:aws_main"
21
+ gcloud-fufu = "fufu_cloud_cli.cli:gcloud_main"
22
+ az-fufu = "fufu_cloud_cli.cli:az_main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ fufu_cloud_cli = ["capabilities.json", "runtime/*/Dockerfile", "runtime/ingress/proxy.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
+
3
+ __version__ = "0.5.0"
@@ -0,0 +1,9 @@
1
+ import sys
2
+
3
+ from .cli import entry
4
+
5
+ if __name__ == "__main__":
6
+ if len(sys.argv) > 1 and sys.argv[1] in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu"}:
7
+ entry(sys.argv[1], sys.argv[2:])
8
+ else:
9
+ entry("fufu", sys.argv[1:])
@@ -0,0 +1,302 @@
1
+ """FUFU credential exchange and tenant-bound API transport (standard library only)."""
2
+ import ipaddress
3
+ import json
4
+ import os
5
+ import re
6
+ import ssl
7
+ import signal
8
+ import threading
9
+ import time
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import quote, urlsplit, urlencode
14
+ from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
15
+
16
+ from .errors import FufuError, require
17
+
18
+ PROVIDERS = {"aws", "gcp", "azure"}
19
+ MAX_RESPONSE = 2 * 1024 * 1024
20
+
21
+
22
+ @contextmanager
23
+ def request_deadline(seconds=20):
24
+ # Linux CLI 的總期限包含 DNS、連線及持續傳送的小分段回應。
25
+ enabled = hasattr(signal, 'setitimer') and threading.current_thread() is threading.main_thread()
26
+ if not enabled:
27
+ yield
28
+ return
29
+ started = time.monotonic()
30
+ previous_handler = signal.getsignal(signal.SIGALRM)
31
+ previous_timer = signal.getitimer(signal.ITIMER_REAL)
32
+ def expired(*args):
33
+ raise FufuError("API_TIMEOUT", "FUFU API 請求超過總執行期限。")
34
+ signal.signal(signal.SIGALRM, expired)
35
+ signal.setitimer(signal.ITIMER_REAL, min(seconds, previous_timer[0]) if previous_timer[0] else seconds)
36
+ try:
37
+ yield
38
+ finally:
39
+ signal.setitimer(signal.ITIMER_REAL, 0)
40
+ signal.signal(signal.SIGALRM, previous_handler)
41
+ if previous_timer[0]:
42
+ signal.setitimer(signal.ITIMER_REAL, max(0.001, previous_timer[0] - (time.monotonic() - started)), previous_timer[1])
43
+
44
+
45
+ class NoRedirect(HTTPRedirectHandler):
46
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
47
+ return None
48
+
49
+
50
+ def endpoint_url(value):
51
+ require(isinstance(value, str) and value == value.strip(), "INVALID_ENDPOINT", "請提供有效的 FUFU API endpoint。")
52
+ parsed = urlsplit(value)
53
+ loopback = parsed.hostname == "localhost"
54
+ try:
55
+ loopback = loopback or ipaddress.ip_address(parsed.hostname).is_loopback
56
+ except ValueError:
57
+ pass
58
+ require(parsed.hostname and not parsed.username and not parsed.password
59
+ and not parsed.query and not parsed.fragment and parsed.path in {"", "/"}
60
+ and not any(c.isspace() for c in value) and "\\" not in value
61
+ and (parsed.scheme == "https" or (parsed.scheme == "http" and loopback)),
62
+ "INVALID_ENDPOINT", "FUFU endpoint 必須使用 HTTPS;本機 loopback 可使用 HTTP。")
63
+ _ = parsed.port
64
+ return value.rstrip("/")
65
+
66
+
67
+ def api_selected():
68
+ mode = os.environ.get("FUFU_BACKEND")
69
+ require(mode in {None, "api", "local-docker", "docker"}, "INVALID_BACKEND", "FUFU_BACKEND 必須是 api 或 local-docker。")
70
+ if mode in {"local-docker", "docker"}:
71
+ return False
72
+ return mode == "api" or any(os.environ.get(k) for k in (
73
+ "FUFU_ENDPOINT", "FUFU_CREDENTIAL_FILE", "FUFU_AWS_CREDENTIAL_FILE",
74
+ "FUFU_GCP_CREDENTIAL_FILE", "FUFU_AZURE_CREDENTIAL_FILE", 'FUFU_SESSION_FILE')) or os.environ.get("AWS_ACCESS_KEY_ID", "").startswith("FUFU")
75
+
76
+
77
+ def load_credential(provider=None, filename=None):
78
+ require(provider is None or provider in PROVIDERS, "INVALID_PROVIDER", "未知的 provider。")
79
+ filename = filename or (os.environ.get(f"FUFU_{provider.upper()}_CREDENTIAL_FILE") if provider else None) or os.environ.get("FUFU_CREDENTIAL_FILE")
80
+ if not filename and provider == "gcp":
81
+ filename = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
82
+ if filename:
83
+ p = Path(filename).expanduser()
84
+ require(p.is_file() and p.stat().st_size <= 16384, "INVALID_CREDENTIAL", "憑證檔案不存在或格式無效。")
85
+ data = json.loads(p.read_text(encoding="utf-8"))
86
+ elif provider == "aws" and os.environ.get("AWS_ACCESS_KEY_ID", "").startswith("FUFUAWS"):
87
+ data = {"fufu_version": 1, "fufu_provider": "aws", "fufu_endpoint": os.environ.get("FUFU_ENDPOINT"),
88
+ "fufu_tenant_id": os.environ.get("FUFU_TENANT_ID"), "aws_access_key_id": os.environ["AWS_ACCESS_KEY_ID"],
89
+ "aws_secret_access_key": os.environ.get("AWS_SECRET_ACCESS_KEY")}
90
+ else:
91
+ raise FufuError("CREDENTIAL_REQUIRED", "請透過 FUFU_CREDENTIAL_FILE 或各 provider 的 FUFU_*_CREDENTIAL_FILE 注入憑證檔案。")
92
+ require(isinstance(data, dict) and data.get("fufu_version") == 1
93
+ and data.get("fufu_provider") in PROVIDERS, "INVALID_CREDENTIAL", "需要 FUFU 專用憑證;不接受正式雲端金鑰。")
94
+ actual = data["fufu_provider"]
95
+ require(provider is None or actual == provider, "PROVIDER_MISMATCH", "憑證所屬 provider 不符。")
96
+ fields = {"aws": ("aws_access_key_id", "aws_secret_access_key"), "gcp": ("client_id", "client_secret"), "azure": ("clientId", "clientSecret")}
97
+ key, secret = (data.get(k) for k in fields[actual])
98
+ require(isinstance(key, str) and re.fullmatch(r"FUFU" + actual.upper() + r"[A-Za-z0-9_-]{12,100}", key)
99
+ and isinstance(secret, str) and 24 <= len(secret) <= 1024,
100
+ "INVALID_CREDENTIAL", "FUFU 憑證欄位無效。")
101
+ if actual == "gcp":
102
+ require(data.get("type") == "fufu_service_account", "INVALID_CREDENTIAL", "需要 FUFU service account JSON。")
103
+ tenant = data.get("fufu_tenant_id")
104
+ require(isinstance(tenant, str) and re.fullmatch(r"tenant-[a-z0-9]{8,32}", tenant), "INVALID_CREDENTIAL", "憑證缺少有效的 tenant。")
105
+ require(not os.environ.get("FUFU_TENANT_ID") or os.environ["FUFU_TENANT_ID"] == tenant,
106
+ "TENANT_MISMATCH", "環境指定的 tenant 與憑證不符。")
107
+ endpoint = endpoint_url(os.environ.get("FUFU_ENDPOINT") or data.get("fufu_endpoint"))
108
+ return {"provider": actual, "credential_id": key, "credential_secret": secret, "tenant_id": tenant}, endpoint
109
+
110
+
111
+ class ApiClient:
112
+ def __init__(self, provider=None, filename=None, read_only=False):
113
+ self.owns_token = True
114
+ self.opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
115
+ if os.environ.get('FUFU_SESSION_FILE') and not filename:
116
+ p = Path(os.environ['FUFU_SESSION_FILE'])
117
+ require(p.is_file() and p.stat().st_size <= 16384, 'INVALID_SESSION', 'Session 檔案無效。')
118
+ session = json.loads(p.read_text())
119
+ self.endpoint = endpoint_url(session.get('endpoint'))
120
+ self.provider, self.token = session.get('provider'), session.get('access_token')
121
+ require(self.provider in PROVIDERS and (provider is None or provider == self.provider)
122
+ and isinstance(self.token, str) and re.fullmatch(r'fufu_at_[A-Za-z0-9_-]{43}', self.token)
123
+ and session.get('expires_at', 0) > time.time(), 'INVALID_SESSION', 'Session 已過期或 provider 不符。')
124
+ self.owns_token = False
125
+ who = self.whoami()
126
+ require((who.get('role') or (session.get('session_kind') == 'terminal' and who.get('sessionKind') == 'terminal'))
127
+ and who.get('kind') == 'cli' and who.get('tenantId') == session.get('tenant_id') and who.get('provider') == self.provider,
128
+ 'INVALID_SESSION', 'Session 的租戶與 role 無效。')
129
+ self.check_selectors(who)
130
+ return
131
+ credential, self.endpoint = load_credential(provider, filename)
132
+ self.provider = credential["provider"]
133
+ self.token = None
134
+ self.opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
135
+ scopes = [f"{self.provider}:read"] + ([] if read_only else [f"{self.provider}:write"])
136
+ issued = self._request("POST", "/api/auth/token", {**credential, "scopes": scopes,
137
+ **({'assume_role': os.environ['FUFU_ASSUME_ROLE']} if os.environ.get('FUFU_ASSUME_ROLE') else {})})
138
+ token = issued.get("access_token")
139
+ require(isinstance(token, str) and re.fullmatch(r"fufu_at_[A-Za-z0-9_-]{43}", token),
140
+ "INVALID_RESPONSE", "API 回傳的權杖格式無效。")
141
+ self.token = token
142
+ p = issued.get("principal", {})
143
+ if p.get("provider") != self.provider or p.get("tenantId") != credential["tenant_id"] or p.get("kind") != "cli":
144
+ self.close()
145
+ raise FufuError("TENANT_MISMATCH", "API 回覆的身分與憑證不符。")
146
+ if os.environ.get('FUFU_SELECTED_REGION') or os.environ.get('FUFU_SELECTED_PROJECT'):
147
+ try: self.check_selectors(self.whoami())
148
+ except Exception:
149
+ self.close(); raise
150
+
151
+ def check_selectors(self, who):
152
+ for env, field in [('FUFU_SELECTED_REGION', 'region'), ('FUFU_SELECTED_PROJECT', 'projectId')]:
153
+ require(not os.environ.get(env) or os.environ[env] == who.get(field), 'RESOURCE_SCOPE_MISMATCH', 'region/project 必須符合目前憑證的 FUFU scope。')
154
+
155
+ def _request(self, method, path, body=None, *, large=False):
156
+ require(path.startswith("/api/") and "\r" not in path and "\n" not in path,
157
+ "INVALID_REQUEST", "API 路徑無效。")
158
+ headers = {"Accept": "application/json", "User-Agent": "fufu-cloud-cli"}
159
+ if self.token:
160
+ headers["Authorization"] = f"Bearer {self.token}"
161
+ data = None if body is None else json.dumps(body).encode("utf-8")
162
+ if data is not None:
163
+ require(len(data) <= (12 * 1024**2 if large else 65536), "PAYLOAD_LIMIT", "API JSON payload 超過上限。")
164
+ headers["Content-Type"] = "application/json"
165
+ req = Request(self.endpoint + path, data=data, headers=headers, method=method)
166
+ try:
167
+ with request_deadline(), self.opener.open(req, timeout=15) as response:
168
+ limit = 9 * 1024**2 if large else MAX_RESPONSE
169
+ raw = response.read(limit + 1)
170
+ require(len(raw) <= limit, "RESPONSE_LIMIT", "API 回覆超過大小限制。")
171
+ return json.loads(raw)
172
+ except HTTPError as exc:
173
+ status = exc.code
174
+ exc.close()
175
+ codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 429: "RATE_LIMITED"}
176
+ raise FufuError(codes.get(status, "API_REQUEST_FAILED"), f"FUFU API 拒絕請求(HTTP {status});未輸出憑證或原始回應。") from None
177
+ except (URLError, TimeoutError, ConnectionError) as exc:
178
+ raise FufuError("API_UNREACHABLE", "無法在時限內安全連線至 FUFU API。") from None
179
+
180
+ def request(self, method, path, body=None):
181
+ return self._request(method, f"/api/cloud/{self.provider}" + path, body, large=path.startswith(('/compute/', '/s3/', '/storage/', '/control/native')))
182
+
183
+ def whoami(self):
184
+ data = self._request("GET", "/api/auth/whoami")
185
+ return {k: v for k, v in data.items() if k != "credentialId"}
186
+
187
+ def close(self):
188
+ if self.token and self.owns_token:
189
+ try:
190
+ self._request("POST", "/api/auth/revoke", {})
191
+ except (FufuError, ValueError, OSError):
192
+ pass
193
+ finally:
194
+ self.token = None
195
+
196
+ def __enter__(self):
197
+ return self
198
+
199
+ def __exit__(self, *args):
200
+ self.close()
201
+
202
+
203
+ def segment(value):
204
+ require(isinstance(value, str) and value not in {"", ".", ".."} and "/" not in value,
205
+ "INVALID_RESOURCE", "資源名稱無效。")
206
+ return quote(value, safe="")
207
+
208
+
209
+ def storage_path(value):
210
+ parsed = urlsplit(value)
211
+ require(parsed.scheme == "gs" and parsed.netloc and not parsed.query and not parsed.fragment
212
+ and not parsed.username and not parsed.password, "INVALID_RESOURCE", "請使用 gs://bucket 或 gs://bucket/object。")
213
+ return segment(parsed.netloc), parsed.path.lstrip("/")
214
+
215
+
216
+ def remote_command(provider, argv, Parser):
217
+ p = Parser(prog={"aws": "aws-fufu", "gcp": "gcloud-fufu", "azure": "az-fufu"}[provider],
218
+ description="FUFU API 憑證與租戶資源操作子集;無原生雲端 IAM/部署相容承諾。")
219
+ roots = p.add_subparsers(dest="service", required=True)
220
+ if provider == "aws":
221
+ roots.add_parser("sts").add_subparsers(dest="action", required=True).add_parser("get-caller-identity")
222
+ sub = roots.add_parser("dynamodb").add_subparsers(dest="action", required=True)
223
+ sub.add_parser("list-tables")
224
+ for action in ("create-table", "delete-table", "put-item", "scan"):
225
+ q = sub.add_parser(action)
226
+ q.add_argument("--table-name", required=True)
227
+ if action == "create-table":
228
+ q.add_argument("--partition-key", default="id", help="FUFU 字串 HASH key;預設 id")
229
+ if action == "put-item":
230
+ q.add_argument("--item", required=True, help="DynamoDB AttributeValue JSON,建議 file:// 路徑")
231
+ elif provider == "gcp":
232
+ roots.add_parser("auth").add_subparsers(dest="action", required=True).add_parser("list")
233
+ sub = roots.add_parser("storage").add_subparsers(dest="action", required=True)
234
+ buckets = sub.add_parser("buckets").add_subparsers(dest="bucket_action", required=True)
235
+ buckets.add_parser("list")
236
+ for action in ("create", "delete"):
237
+ buckets.add_parser(action).add_argument("url")
238
+ sub.add_parser("ls").add_argument("url")
239
+ sub.add_parser("cat").add_argument("url")
240
+ cp = sub.add_parser("cp")
241
+ cp.add_argument("source")
242
+ cp.add_argument("destination")
243
+ sub.add_parser("rm").add_argument("url")
244
+ else:
245
+ roots.add_parser("account").add_subparsers(dest="action", required=True).add_parser("show")
246
+ for group in ("group", "resource"):
247
+ sub = roots.add_parser(group).add_subparsers(dest="action", required=True)
248
+ sub.add_parser("list")
249
+ for action in ("show", "delete"):
250
+ sub.add_parser(action).add_argument("--name", required=True)
251
+ if group == "group":
252
+ q = sub.add_parser("create")
253
+ q.add_argument("--name", required=True)
254
+ q.add_argument("--location", default="eastasia")
255
+ a = p.parse_args(argv)
256
+ read_only = a.service in {"sts", "auth", "account"} or a.action in {"list", "list-tables", "scan", "show", "ls", "cat"} or getattr(a, "bucket_action", None) == "list" or (a.action == 'cp' and a.source.startswith('gs://'))
257
+ with ApiClient(provider, read_only=read_only) as api:
258
+ if a.service in {"sts", "auth", "account"}:
259
+ return api.whoami()
260
+ if provider == "aws":
261
+ if a.action == "list-tables":
262
+ return api.request("GET", "/dynamodb/tables")
263
+ path = "/dynamodb/tables/" + segment(a.table_name)
264
+ if a.action == "create-table":
265
+ return api.request("POST", "/dynamodb/tables", {"TableName": a.table_name, "PartitionKey": a.partition_key})
266
+ if a.action == "delete-table":
267
+ return api.request("DELETE", path)
268
+ if a.action == "scan":
269
+ return api.request("GET", path + "/items")
270
+ from .cli import payload
271
+ return api.request("POST", path + "/items", {"Item": json.loads(payload(a.item))})
272
+ if provider == "azure":
273
+ if a.action == "list":
274
+ result = api.request("GET", "/resources")
275
+ return {"value": [r for r in result["value"] if r.get("kind") == "resource-groups"]} if a.service == "group" else result
276
+ if a.action == "create":
277
+ return api.request("POST", "/resources", {"name": a.name, "location": a.location})
278
+ return api.request("GET" if a.action == "show" else "DELETE", "/resources/" + segment(a.name))
279
+ if a.action == "buckets" and a.bucket_action == "list":
280
+ return api.request("GET", "/storage/buckets")
281
+ if a.action == 'cp' and a.source.startswith('gs://'):
282
+ from .cloud_data import download_bytes
283
+ require(not a.destination.startswith('gs://'), 'UNSUPPORTED_ARGUMENT', 'cp 目前只支援本機與 Storage 之間傳輸。')
284
+ bucket, key = storage_path(a.source)
285
+ require(bool(key), 'INVALID_RESOURCE', '請指定 object 名稱。')
286
+ result = api.request('GET', '/storage/buckets/' + bucket + '/object?' + urlencode({'name': key, 'encoding': 'base64'}))
287
+ return download_bytes(a.destination, result['contentBase64'])
288
+ bucket, key = storage_path(a.destination if a.action == "cp" else a.url)
289
+ path = "/storage/buckets/" + bucket
290
+ if a.action == "buckets":
291
+ require(not key, "INVALID_RESOURCE", "Bucket 操作不接受 object 路徑。")
292
+ return api.request("POST", "/storage/buckets", {"name": bucket}) if a.bucket_action == "create" else api.request("DELETE", path)
293
+ if a.action == "ls":
294
+ require(not key, "UNSUPPORTED_ARGUMENT", "ls 目前只支援 bucket 根目錄。")
295
+ return api.request("GET", path + "/objects")
296
+ require(bool(key), "INVALID_RESOURCE", "請指定 object 名稱。")
297
+ if a.action == "cat":
298
+ return api.request("GET", path + "/object?" + urlencode({"name": key}))
299
+ if a.action == "rm":
300
+ return api.request("DELETE", path + "/object?" + urlencode({"name": key}))
301
+ from .cloud_data import upload_bytes
302
+ return api.request("POST", path + "/objects", {"name": key, "contentBase64": upload_bytes(a.source)})