fufu-cloud-cli 0.10.1__tar.gz → 0.11.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.10.1 → fufu_cloud_cli-0.11.0}/PKG-INFO +25 -3
- fufu_cloud_cli-0.10.1/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md +23 -25
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/pyproject.toml +2 -2
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/api_backend.py +16 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capabilities.json +4 -4
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cli.py +7 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/docker_backend.py +24 -10
- fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/gateway_contract.py +8 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_sdk.py +7 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/infrastructure.py +62 -13
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime_sdk.py +50 -3
- fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/s3_highlevel.py +106 -0
- fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/usage.py +48 -0
- fufu_cloud_cli-0.10.1/README.md → fufu_cloud_cli-0.11.0/src/fufu_cloud_cli.egg-info/PKG-INFO +47 -2
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +3 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/LICENSE +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/setup.cfg +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_errors.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_lambda.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_output.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/bigquery.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/control.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_output.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_infra.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcs.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/observability.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pubsub.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/remote_compute.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/storage.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/__init__.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/aws_text.py +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.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.11.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
|
|
@@ -19,6 +19,7 @@ Provides-Extra: gcp
|
|
|
19
19
|
Requires-Dist: google-cloud-bigquery==3.38.0; extra == "gcp"
|
|
20
20
|
Requires-Dist: google-cloud-tasks==2.20.0; extra == "gcp"
|
|
21
21
|
Requires-Dist: google-cloud-logging==3.12.1; extra == "gcp"
|
|
22
|
+
Requires-Dist: google-cloud-storage==3.4.1; extra == "gcp"
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
# FUFU Cloud CLI
|
|
@@ -27,6 +28,27 @@ Dynamic: license-file
|
|
|
27
28
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
28
29
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
29
30
|
|
|
31
|
+
0.11.0 新增獨占 GPU VM 的租用命令。需由平台啟用並驗證對應實體節點;
|
|
32
|
+
支援的 RTX 3060 為整卡直通至 KVM 客體,可執行 NVIDIA 驅動與 CUDA。
|
|
33
|
+
停止 VM 仍保留整卡,刪除後才釋放。GPU 使用中或已保留會明確拒絕,
|
|
34
|
+
不會自動中斷其他工作。CLI 不包含節點 SSH 金鑰、主機裝置管理程式或平台設定。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
gcloud-fufu compute instances create gpu-demo \
|
|
38
|
+
--zone=GPU_ZONE --machine-type=n1-standard-4 \
|
|
39
|
+
--accelerator=type=nvidia-geforce-rtx-3060,count=1 \
|
|
40
|
+
--maintenance-policy=TERMINATE
|
|
41
|
+
gcloud-fufu compute ssh gpu-demo --zone=GPU_ZONE --command='nvidia-smi'
|
|
42
|
+
gcloud-fufu compute instances delete gpu-demo --zone=GPU_ZONE
|
|
43
|
+
|
|
44
|
+
aws-fufu ec2 run-instances --image-id=fufu-debian-12-gpu \
|
|
45
|
+
--instance-type=fufu.rtx3060.xlarge --placement=AvailabilityZone=GPU_ZONE
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`GPU_ZONE` 使用平台 GPU 選單提供的區域。`nvidia-geforce-rtx-3060` 與
|
|
49
|
+
`fufu.rtx3060.xlarge` 是 FUFU 的硬體擴充名稱;遷移到官方雲端時需改為官方
|
|
50
|
+
提供的機型、加速器與區域。這不表示與官方 GPU 型號、HA、計費或 IAM 完全等價。
|
|
51
|
+
|
|
30
52
|
0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
|
|
31
53
|
S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
|
|
32
54
|
KMS、bucket IAM、GCS bucket labels 或 force destroy。
|
|
@@ -39,7 +61,7 @@ get-credentials/update-kubeconfig、waiter 與刪除。
|
|
|
39
61
|
取得憑證後可使用標準 kubectl 部署 Deployment、Service、Job 及受管 GPU 容器。
|
|
40
62
|
工作站需另安裝 kubectl;配套 Cloud Shell 已提供。
|
|
41
63
|
|
|
42
|
-
VM 尚無公開 IP、複寫儲存、live migration
|
|
64
|
+
VM 尚無公開 IP、複寫儲存、live migration 或 GPU 熱插拔;
|
|
43
65
|
叢集尚無 workload PVC、公開 LoadBalancer、互動式 exec/attach/port-forward。
|
|
44
66
|
這批新 VM/cluster API 尚未支援官方 Terraform/OpenTofu/Pulumi provider。
|
|
45
67
|
原有 firewall、global LB、Redis 等 typed configuration 仍明示 `MOCK ONLY`,
|
|
@@ -135,7 +157,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
135
157
|
## Cloudflare 前端部署與 R2
|
|
136
158
|
|
|
137
159
|
```bash
|
|
138
|
-
pip install 'fufu-cloud-cli==0.
|
|
160
|
+
pip install 'fufu-cloud-cli==0.11.0'
|
|
139
161
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
140
162
|
wrangler-fufu capabilities
|
|
141
163
|
wrangler-fufu pages project create frontend --production-branch main
|
fufu_cloud_cli-0.10.1/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md
RENAMED
|
@@ -1,32 +1,30 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: fufu-cloud-cli
|
|
3
|
-
Version: 0.10.1
|
|
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
|
-
Requires-Dist: jmespath==1.1.0
|
|
11
|
-
Requires-Dist: tomli==2.2.1; python_version < "3.11"
|
|
12
|
-
Provides-Extra: yaml
|
|
13
|
-
Requires-Dist: PyYAML==6.0.3; extra == "yaml"
|
|
14
|
-
Provides-Extra: test
|
|
15
|
-
Requires-Dist: boto3==1.40.66; extra == "test"
|
|
16
|
-
Provides-Extra: terraform
|
|
17
|
-
Requires-Dist: python-hcl2==7.3.1; extra == "terraform"
|
|
18
|
-
Provides-Extra: gcp
|
|
19
|
-
Requires-Dist: google-cloud-bigquery==3.38.0; extra == "gcp"
|
|
20
|
-
Requires-Dist: google-cloud-tasks==2.20.0; extra == "gcp"
|
|
21
|
-
Requires-Dist: google-cloud-logging==3.12.1; extra == "gcp"
|
|
22
|
-
Dynamic: license-file
|
|
23
|
-
|
|
24
1
|
# FUFU Cloud CLI
|
|
25
2
|
|
|
26
3
|
以六個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
|
|
27
4
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
28
5
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
29
6
|
|
|
7
|
+
0.11.0 新增獨占 GPU VM 的租用命令。需由平台啟用並驗證對應實體節點;
|
|
8
|
+
支援的 RTX 3060 為整卡直通至 KVM 客體,可執行 NVIDIA 驅動與 CUDA。
|
|
9
|
+
停止 VM 仍保留整卡,刪除後才釋放。GPU 使用中或已保留會明確拒絕,
|
|
10
|
+
不會自動中斷其他工作。CLI 不包含節點 SSH 金鑰、主機裝置管理程式或平台設定。
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gcloud-fufu compute instances create gpu-demo \
|
|
14
|
+
--zone=GPU_ZONE --machine-type=n1-standard-4 \
|
|
15
|
+
--accelerator=type=nvidia-geforce-rtx-3060,count=1 \
|
|
16
|
+
--maintenance-policy=TERMINATE
|
|
17
|
+
gcloud-fufu compute ssh gpu-demo --zone=GPU_ZONE --command='nvidia-smi'
|
|
18
|
+
gcloud-fufu compute instances delete gpu-demo --zone=GPU_ZONE
|
|
19
|
+
|
|
20
|
+
aws-fufu ec2 run-instances --image-id=fufu-debian-12-gpu \
|
|
21
|
+
--instance-type=fufu.rtx3060.xlarge --placement=AvailabilityZone=GPU_ZONE
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`GPU_ZONE` 使用平台 GPU 選單提供的區域。`nvidia-geforce-rtx-3060` 與
|
|
25
|
+
`fufu.rtx3060.xlarge` 是 FUFU 的硬體擴充名稱;遷移到官方雲端時需改為官方
|
|
26
|
+
提供的機型、加速器與區域。這不表示與官方 GPU 型號、HA、計費或 IAM 完全等價。
|
|
27
|
+
|
|
30
28
|
0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
|
|
31
29
|
S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
|
|
32
30
|
KMS、bucket IAM、GCS bucket labels 或 force destroy。
|
|
@@ -39,7 +37,7 @@ get-credentials/update-kubeconfig、waiter 與刪除。
|
|
|
39
37
|
取得憑證後可使用標準 kubectl 部署 Deployment、Service、Job 及受管 GPU 容器。
|
|
40
38
|
工作站需另安裝 kubectl;配套 Cloud Shell 已提供。
|
|
41
39
|
|
|
42
|
-
VM 尚無公開 IP、複寫儲存、live migration
|
|
40
|
+
VM 尚無公開 IP、複寫儲存、live migration 或 GPU 熱插拔;
|
|
43
41
|
叢集尚無 workload PVC、公開 LoadBalancer、互動式 exec/attach/port-forward。
|
|
44
42
|
這批新 VM/cluster API 尚未支援官方 Terraform/OpenTofu/Pulumi provider。
|
|
45
43
|
原有 firewall、global LB、Redis 等 typed configuration 仍明示 `MOCK ONLY`,
|
|
@@ -135,7 +133,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
135
133
|
## Cloudflare 前端部署與 R2
|
|
136
134
|
|
|
137
135
|
```bash
|
|
138
|
-
pip install 'fufu-cloud-cli==0.
|
|
136
|
+
pip install 'fufu-cloud-cli==0.11.0'
|
|
139
137
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
140
138
|
wrangler-fufu capabilities
|
|
141
139
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fufu-cloud-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.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"
|
|
@@ -16,7 +16,7 @@ license-files = ["LICENSE"]
|
|
|
16
16
|
yaml = ["PyYAML==6.0.3"]
|
|
17
17
|
test = ["boto3==1.40.66"]
|
|
18
18
|
terraform = ["python-hcl2==7.3.1"]
|
|
19
|
-
gcp = ["google-cloud-bigquery==3.38.0", "google-cloud-tasks==2.20.0", "google-cloud-logging==3.12.1"]
|
|
19
|
+
gcp = ["google-cloud-bigquery==3.38.0", "google-cloud-tasks==2.20.0", "google-cloud-logging==3.12.1", "google-cloud-storage==3.4.1"]
|
|
20
20
|
|
|
21
21
|
[project.scripts]
|
|
22
22
|
fufu = "fufu_cloud_cli.cli:main"
|
|
@@ -184,6 +184,22 @@ class ApiClient:
|
|
|
184
184
|
return json.loads(raw)
|
|
185
185
|
except HTTPError as exc:
|
|
186
186
|
status = exc.code
|
|
187
|
+
if '/infrastructure/' in path and status in {409, 422, 503, 504}:
|
|
188
|
+
try:
|
|
189
|
+
value = json.loads(exc.read(4096))
|
|
190
|
+
code = value.get('code') if isinstance(value, dict) else None
|
|
191
|
+
messages = {
|
|
192
|
+
'GPU_IN_USE': '符合條件的 GPU 正在使用;未搶占現有工作或重複出租。',
|
|
193
|
+
'GPU_RESERVED': 'GPU 已由另一台 VM 保留;停止 VM 仍保留 GPU,刪除後才會釋放。',
|
|
194
|
+
'GPU_RECOVERY_REQUIRED': 'GPU 正在等待安全恢復;平台保留所有權,恢復前不接受再次租用。',
|
|
195
|
+
'GPU_NODE_UNAVAILABLE': 'GPU 節點暫時無法連線;請稍後重試。',
|
|
196
|
+
'GPU_TRANSITION_TIMEOUT': 'GPU 裝置交接逾時;所有權仍受保護,請檢查 VM 狀態。',
|
|
197
|
+
'INSUFFICIENT_NODE_STORAGE': '目標 GPU 節點的 SSD 可用空間不足。',
|
|
198
|
+
}
|
|
199
|
+
if code in messages:
|
|
200
|
+
raise FufuError(code, messages[code]) from None
|
|
201
|
+
except (ValueError, OSError): pass
|
|
202
|
+
finally: exc.close()
|
|
187
203
|
if '/infrastructure/' in path and status == 429:
|
|
188
204
|
try:
|
|
189
205
|
value = json.loads(exc.read(4096))
|
|
@@ -544,9 +544,9 @@
|
|
|
544
544
|
"backend": "api",
|
|
545
545
|
"mode": "simplified",
|
|
546
546
|
"operations": [
|
|
547
|
-
"create-list-describe-delete", "stop-start", "guest-ssh-command"
|
|
547
|
+
"create-list-describe-delete", "stop-start", "guest-ssh-command", "exclusive-gpu-vm"
|
|
548
548
|
],
|
|
549
|
-
"limitations": ["requires-FUFU-infrastructure-backend", "Debian-12-only", "private-network", "single-node-disk", "
|
|
549
|
+
"limitations": ["requires-FUFU-infrastructure-backend", "Debian-12-only", "private-network", "single-node-disk", "GPU-requires-enabled-validated-node", "GPU-exclusive-until-delete", "no-GPU-hotplug-or-live-migration", "no-official-IaC-provider"],
|
|
550
550
|
"reason": "實際 KVM/KubeVirt VM、持久磁碟與 SSH 指令;原生風格指令子集,無官方雲端資源。"
|
|
551
551
|
},
|
|
552
552
|
"gcp-gke": {
|
|
@@ -648,8 +648,8 @@
|
|
|
648
648
|
"status": "implemented",
|
|
649
649
|
"backend": "api",
|
|
650
650
|
"mode": "simplified",
|
|
651
|
-
"operations": ["run-describe-terminate-instances", "stop-start", "instance-waiters"],
|
|
652
|
-
"limitations": ["requires-FUFU-infrastructure-backend", "Debian-12-only", "private-network", "single-node-disk", "
|
|
651
|
+
"operations": ["run-describe-terminate-instances", "stop-start", "instance-waiters", "exclusive-gpu-vm"],
|
|
652
|
+
"limitations": ["requires-FUFU-infrastructure-backend", "Debian-12-only", "private-network", "single-node-disk", "GPU-requires-enabled-validated-node", "GPU-exclusive-until-delete", "no-GPU-hotplug-or-live-migration", "no-official-IaC-provider"],
|
|
653
653
|
"reason": "實際 KVM/KubeVirt VM;支援生命週期與 scoped instance ID,無官方 EC2 網路或完整 API。"
|
|
654
654
|
},
|
|
655
655
|
"aws-ecs": {
|
|
@@ -128,6 +128,7 @@ def fufu_parser():
|
|
|
128
128
|
sub.add_parser('pulumi', help='官方 Pulumi Node.js SDK;使用獨立 FUFU 開發 state')
|
|
129
129
|
sub.add_parser('state', help='管理 Terraform/OpenTofu 遠端 state、版本與鎖')
|
|
130
130
|
sub.add_parser('infrastructure', help='GCP 基礎設施配置 mock 的查詢與 CRUD;不啟動資源')
|
|
131
|
+
sub.add_parser('usage', help='逐台 VM 的月用量、電量分攤估算與成本明細')
|
|
131
132
|
for action in ("plan", "deploy"):
|
|
132
133
|
q = sub.add_parser(action)
|
|
133
134
|
q.add_argument("--file", required=True)
|
|
@@ -515,6 +516,12 @@ def entry(provider, argv=None):
|
|
|
515
516
|
if provider == 'fufu' and argv[:1] == ['state']:
|
|
516
517
|
from .iac_state import command
|
|
517
518
|
emit(command(argv[1:], Parser)); return
|
|
519
|
+
if provider == 'fufu' and argv[:1] == ['usage']:
|
|
520
|
+
from .usage import command
|
|
521
|
+
value = command(argv[1:], Parser)
|
|
522
|
+
if isinstance(value, str): sys.stdout.write(value)
|
|
523
|
+
else: emit(value)
|
|
524
|
+
return
|
|
518
525
|
if provider == 'fufu' and argv[:1] == ['infrastructure']:
|
|
519
526
|
from .gcp_infra import configuration_command
|
|
520
527
|
emit(configuration_command(argv[1:], Parser)); return
|
|
@@ -38,6 +38,7 @@ INGRESS_IMAGE = "fufu-cloud-cli-ingress:local"
|
|
|
38
38
|
LABEL = "io.fufu.cloud-cli"
|
|
39
39
|
KINDS = {"cloud-run-http", "cloud-run-functions-http", "cloud-functions-gen1-http", "aws-lambda-sync"}
|
|
40
40
|
from .limits import compute_limits, MAX_SOURCE_FILES, size_label, workload_resources
|
|
41
|
+
from .gateway_contract import api_gateway_labels
|
|
41
42
|
from .capacity import run_admitted
|
|
42
43
|
|
|
43
44
|
MAX_PAYLOAD = 32 * 1024 * 1024
|
|
@@ -284,8 +285,8 @@ class Backend:
|
|
|
284
285
|
require(gateway_container is not None, 'GATEWAY_UNAVAILABLE', 'Workload gateway 尚未配置。')
|
|
285
286
|
gateway = object_json(docker('container', 'inspect', gateway_container, '--format',
|
|
286
287
|
'{"id":{{json .Id}},"labels":{{json .Config.Labels}},"networks":{{json .NetworkSettings.Networks}},"running":{{json .State.Running}}}'))
|
|
287
|
-
require(gateway['running'] and gateway['labels']
|
|
288
|
-
'GATEWAY_UNAVAILABLE', '指定 gateway
|
|
288
|
+
require(gateway['running'] and api_gateway_labels(gateway['labels']),
|
|
289
|
+
'GATEWAY_UNAVAILABLE', '指定 gateway 必須是正在執行且已核准的 FUFU API 入口。')
|
|
289
290
|
if network not in gateway['networks']:
|
|
290
291
|
docker('network', 'connect', '--alias', 'fufu-api', network, gateway['id'])
|
|
291
292
|
revision = str(uuid.uuid4()) if kind == 'aws-lambda-sync' else f"{service}-{uuid.uuid4().hex[:8]}"
|
|
@@ -325,7 +326,7 @@ class Backend:
|
|
|
325
326
|
'lastModified': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:23] + '+0000',
|
|
326
327
|
'architecture': {'x86_64': 'x86_64', 'aarch64': 'arm64'}.get(platform.machine()),
|
|
327
328
|
**({'network': network, 'gateway_id': gateway['id']} if gateway else {}),
|
|
328
|
-
**({'role': role, 'sdkTransport': 'fufu-workload-token', 'iamEnforced': True} if role else {}),
|
|
329
|
+
**({'role': role, 'sdkTransport': 'aws-sigv4-wire' if (runtime_env or {}).get('FUFU_AWS_SDK_TRANSPORT') == 'native-v1' else 'fufu-workload-token', 'iamEnforced': True} if role else {}),
|
|
329
330
|
**({'build': build} if build else {}),
|
|
330
331
|
**({'source': source} if source else {}),
|
|
331
332
|
**({'imageReference': image_reference} if image_reference else {}),
|
|
@@ -354,7 +355,8 @@ class Backend:
|
|
|
354
355
|
"--network", self.ingress_network, "--publish", "127.0.0.1::8080",
|
|
355
356
|
"--user", "10001:10001", "--read-only", "--cap-drop", "ALL",
|
|
356
357
|
"--security-opt", "no-new-privileges:true", "--pids-limit", "64", "--memory", "64m", "--cpus", "0.25",
|
|
357
|
-
"--log-
|
|
358
|
+
"--log-driver", "local", "--log-opt", "max-size=1m",
|
|
359
|
+
"--log-opt", "max-file=2", "--log-opt", "compress=true",
|
|
358
360
|
"--env", "FUFU_UPSTREAM_HOST", "--env", "FUFU_UPSTREAM_PORT", ingress_image_id,
|
|
359
361
|
env={"FUFU_UPSTREAM_HOST": cname, "FUFU_UPSTREAM_PORT": str(port)})
|
|
360
362
|
docker("network", "connect", network, ingress_candidate)
|
|
@@ -566,11 +568,11 @@ def prepare_runtime(runtimes=("python3.12",)):
|
|
|
566
568
|
return {"prepared": ["functions-python312", "ingress", *runtimes], "backend": "local-docker"}
|
|
567
569
|
|
|
568
570
|
|
|
569
|
-
def build_function(source, target, *, network="default"):
|
|
571
|
+
def build_function(source, target, *, network="default", image_store=None):
|
|
570
572
|
require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", target or ""), "INVALID_HANDLER", "入口必須是有效 Python 函式名稱。")
|
|
571
573
|
src = Path(source).resolve()
|
|
572
574
|
require(src.is_dir() and (src / "main.py").is_file(), "INVALID_SOURCE", "Python function 來源需包含 main.py。")
|
|
573
|
-
inspect_image(FUNCTIONS_IMAGE)
|
|
575
|
+
(image_store.inspect_image if image_store else inspect_image)(FUNCTIONS_IMAGE)
|
|
574
576
|
base = FUNCTIONS_IMAGE
|
|
575
577
|
with tempfile.TemporaryDirectory(prefix="fufu-function-") as tmp:
|
|
576
578
|
dst = Path(tmp)
|
|
@@ -595,6 +597,12 @@ def build_function(source, target, *, network="default"):
|
|
|
595
597
|
requirements = dst / "app/requirements.txt"
|
|
596
598
|
if not requirements.exists():
|
|
597
599
|
requirements.write_text("")
|
|
600
|
+
if image_store:
|
|
601
|
+
require(not requirements.read_text().strip(), 'DEPENDENCIES_NOT_PREPARED',
|
|
602
|
+
'離線 Function 配方需要預先準備相依套件。')
|
|
603
|
+
return image_store.layer(base, dst / 'app', '/app', [
|
|
604
|
+
'functions-framework', '--target', target, '--signature-type', 'http',
|
|
605
|
+
'--host', '0.0.0.0', '--port', '8080'], workdir='/app')
|
|
598
606
|
# 原始 requirements 可安裝應用相依;build 有網路,runtime 不開放 egress。
|
|
599
607
|
dockerfile = (f"FROM {base}\nUSER 0\nCOPY app/requirements.txt /tmp/requirements.txt\n"
|
|
600
608
|
"RUN python -m pip install --index-url https://pypi.org/simple --disable-pip-version-check --no-cache-dir --retries 1 --timeout 20 -r /tmp/requirements.txt\n"
|
|
@@ -637,11 +645,11 @@ def extract_zip(archive, destination, *, kind="aws-lambda-sync"):
|
|
|
637
645
|
raise FufuError("INVALID_ZIP", "ZIP 無法安全解壓縮。") from exc
|
|
638
646
|
|
|
639
647
|
|
|
640
|
-
def build_lambda(archive, runtime, handler):
|
|
648
|
+
def build_lambda(archive, runtime, handler, *, image_store=None):
|
|
641
649
|
require(runtime in LAMBDA_IMAGES, "UNSUPPORTED_RUNTIME", "目前只支援 Python 3.10 與 3.12 Lambda ZIP。")
|
|
642
650
|
require(re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+", handler or ""), "INVALID_HANDLER", "Lambda handler 格式應為 module.function。")
|
|
643
651
|
base = LAMBDA_IMAGES[runtime]
|
|
644
|
-
inspect_image(base)
|
|
652
|
+
(image_store.inspect_image if image_store else inspect_image)(base)
|
|
645
653
|
with tempfile.TemporaryDirectory(prefix="fufu-lambda-") as tmp:
|
|
646
654
|
dst = Path(tmp)
|
|
647
655
|
app = dst / "app"
|
|
@@ -661,17 +669,20 @@ def build_lambda(archive, runtime, handler):
|
|
|
661
669
|
require(not transfer.exists(), 'RESERVED_FILE', '來源與物件傳輸 helper 的保留檔名衝突。')
|
|
662
670
|
shutil.copyfile(Path(__file__).with_name('object_transfer.py'), transfer)
|
|
663
671
|
transfer.chmod(0o644)
|
|
672
|
+
if image_store:
|
|
673
|
+
return image_store.layer(base, app, '/var/task', ['_fufu_runtime_adapter.handler'],
|
|
674
|
+
{'FUFU_HANDLER': handler})
|
|
664
675
|
(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")
|
|
665
676
|
return build_image(dst, f"fufu-cli-lambda:{uuid.uuid4().hex}")
|
|
666
677
|
|
|
667
678
|
|
|
668
|
-
def build_cloud_run(archive, handler, port, tag):
|
|
679
|
+
def build_cloud_run(archive, handler, port, tag, *, image_store=None):
|
|
669
680
|
"""固定 Python ASGI 配方;不執行使用者 Dockerfile、安裝程式或建置 hook。"""
|
|
670
681
|
require(isinstance(handler, str) and re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", handler),
|
|
671
682
|
'INVALID_HANDLER', 'ASGI entry point 必須是 module:app。')
|
|
672
683
|
require(type(port) is int and 1024 <= port <= 65535, 'INVALID_PORT', '容器 port 範圍為 1024–65535。')
|
|
673
684
|
require(re.fullmatch(r'fufu-source:[a-f0-9]{64}', tag), 'INVALID_IMAGE', '來源映像名稱無效。')
|
|
674
|
-
inspect_image(PYTHON_IMAGE)
|
|
685
|
+
(image_store.inspect_image if image_store else inspect_image)(PYTHON_IMAGE)
|
|
675
686
|
base = PYTHON_IMAGE
|
|
676
687
|
with tempfile.TemporaryDirectory(prefix='fufu-asgi-') as tmp:
|
|
677
688
|
context = Path(tmp)
|
|
@@ -707,6 +718,9 @@ def build_cloud_run(archive, handler, port, tag):
|
|
|
707
718
|
shutil.copyfile(Path(__file__).with_name('object_transfer.py'), transfer)
|
|
708
719
|
transfer.chmod(0o644)
|
|
709
720
|
command = ['python', '-m', 'uvicorn', handler, '--host', '0.0.0.0', '--port', str(port)]
|
|
721
|
+
if image_store:
|
|
722
|
+
return image_store.layer(base, app, '/app', command,
|
|
723
|
+
{'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONUNBUFFERED': '1'}, workdir='/app')
|
|
710
724
|
(context / 'Dockerfile').write_text(
|
|
711
725
|
f'FROM {base}\nCOPY --chown=10001:10001 app/ /app/\nWORKDIR /app\n'
|
|
712
726
|
'ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nUSER 10001:10001\n'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""管理者配置的 API gateway;容器標籤只能由受信任的宿主控制器設定。"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def api_gateway_labels(labels):
|
|
5
|
+
if not isinstance(labels, dict):
|
|
6
|
+
return False
|
|
7
|
+
return labels.get('com.docker.compose.service') == 'local-cloud-console' or \
|
|
8
|
+
labels.get('io.fufu.platform-bridge') == 'console-rke2-v1'
|
|
@@ -38,6 +38,13 @@ def tasks_client():
|
|
|
38
38
|
return tasks_v2.CloudTasksClient(credentials=credentials, transport='rest', client_options={'api_endpoint': endpoint + '/tasks'})
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def storage_client():
|
|
42
|
+
"""Official Storage JSON API with the same managed workload identity."""
|
|
43
|
+
from google.cloud import storage
|
|
44
|
+
endpoint, project, credentials = _configuration()
|
|
45
|
+
return storage.Client(project=project, credentials=credentials, client_options={'api_endpoint': endpoint + '/storage'})
|
|
46
|
+
|
|
47
|
+
|
|
41
48
|
def logging_client():
|
|
42
49
|
from google.cloud import logging_v2
|
|
43
50
|
from google.cloud.logging_v2.resource import Resource
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
import re
|
|
9
9
|
import shutil
|
|
10
10
|
import ssl
|
|
11
|
+
import stat
|
|
11
12
|
import sys
|
|
12
13
|
import tempfile
|
|
13
14
|
import time
|
|
@@ -21,11 +22,12 @@ from .errors import FufuError, require
|
|
|
21
22
|
BASE = '/infrastructure/resources/'
|
|
22
23
|
VM_TYPES = {'e2-micro': (2, 1), 'e2-small': (2, 2), 'e2-medium': (2, 4), 'e2-standard-2': (2, 8),
|
|
23
24
|
'e2-standard-4': (4, 16), 'e2-standard-8': (8, 32), 't3.micro': (2, 1), 't3.small': (2, 2),
|
|
24
|
-
't3.medium': (2, 4), 'm5.large': (2, 8), 'm5.xlarge': (4, 16), 'm5.2xlarge': (8, 32)
|
|
25
|
+
't3.medium': (2, 4), 'm5.large': (2, 8), 'm5.xlarge': (4, 16), 'm5.2xlarge': (8, 32),
|
|
26
|
+
'n1-standard-4': (4, 15), 'n1-standard-8': (8, 30), 'fufu.rtx3060.xlarge': (4, 16)}
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def warn(kind):
|
|
28
|
-
sys.stderr.write('FUFU runtime: ' + ('KVM VM with a private network and one-node persistent disk;
|
|
30
|
+
sys.stderr.write('FUFU runtime: ' + ('KVM VM with a private network and one-node persistent disk; optional exclusive GPU on validated nodes, retained until deletion.\n' if kind == 'vm' else
|
|
29
31
|
'Independent Kubernetes API with shared worker nodes; private ClusterIP services, no workload PVCs or native cloud IAM.\n'))
|
|
30
32
|
|
|
31
33
|
|
|
@@ -55,6 +57,8 @@ def gcp_shape(value, kind):
|
|
|
55
57
|
if kind == 'vm':
|
|
56
58
|
return {'name': value['name'], 'status': {'STOPPED': 'TERMINATED', 'DELETING': 'STOPPING'}.get(value['state'], value['state']),
|
|
57
59
|
'machineType': spec.get('machineType'), 'zone': spec.get('location'),
|
|
60
|
+
**({'guestAccelerators': [{'acceleratorType': spec['gpu']['type'], 'acceleratorCount': spec['gpu']['count']}],
|
|
61
|
+
'scheduling': {'onHostMaintenance': 'TERMINATE'}} if spec.get('gpu') else {}),
|
|
58
62
|
'disks': [{'boot': True, 'diskSizeGb': str(spec.get('diskGiB', 20))}], 'Fufu': fufu_fields(value)}
|
|
59
63
|
return {'name': value['name'], 'status': {'PROVISIONING': 'PROVISIONING', 'RUNNING': 'RUNNING', 'ERROR': 'ERROR', 'DELETING': 'STOPPING'}.get(value['state'], value['state']),
|
|
60
64
|
'location': spec.get('location'), 'currentMasterVersion': spec.get('version'), 'Fufu': fufu_fields(value)}
|
|
@@ -65,13 +69,41 @@ def aws_shape(value, kind, who):
|
|
|
65
69
|
if kind == 'vm':
|
|
66
70
|
identifier = 'i-' + hashlib.sha256((str(who['tenantId']) + value['name'] + value.get('resourceId', value.get('createdAt', ''))).encode()).hexdigest()[:17]
|
|
67
71
|
state = {'RUNNING': (16, 'running'), 'STOPPED': (80, 'stopped'), 'STOPPING': (64, 'stopping'), 'DELETING': (32, 'shutting-down'), 'DELETED': (48, 'terminated')}.get(value['state'], (0, 'pending'))
|
|
68
|
-
return {'InstanceId': identifier, 'InstanceType': spec.get('machineType'), 'ImageId': 'fufu-debian-12',
|
|
72
|
+
return {'InstanceId': identifier, 'InstanceType': spec.get('machineType'), 'ImageId': 'fufu-debian-12-gpu' if spec.get('gpu') else 'fufu-debian-12',
|
|
73
|
+
'Placement': {'AvailabilityZone': spec.get('location')},
|
|
69
74
|
'State': {'Code': state[0], 'Name': state[1]}, 'Tags': [{'Key': 'Name', 'Value': value['name']}], 'Fufu': fufu_fields(value)}
|
|
70
75
|
return {'name': value['name'], 'arn': f"arn:aws:eks:{who['region']}:{who['accountId']}:cluster/{value['name']}",
|
|
71
76
|
'status': {'RUNNING': 'ACTIVE', 'PROVISIONING': 'CREATING', 'DELETING': 'DELETING', 'ERROR': 'FAILED'}.get(value['state'], value['state']),
|
|
72
77
|
'version': spec.get('version'), 'Fufu': fufu_fields(value)}
|
|
73
78
|
|
|
74
79
|
|
|
80
|
+
def read_shell_certificate(certificate):
|
|
81
|
+
"""Read a bounded CA file, including a Kubernetes projected Secret link.
|
|
82
|
+
|
|
83
|
+
Kubelet projects each Secret key through ``..data``. Resolve it inside the
|
|
84
|
+
fixed session mount, then inspect and read one non-following file descriptor.
|
|
85
|
+
External links, special files and writable trust files remain rejected.
|
|
86
|
+
"""
|
|
87
|
+
fd = None
|
|
88
|
+
try:
|
|
89
|
+
target = certificate.resolve(strict=True)
|
|
90
|
+
target.relative_to(certificate.parent.resolve(strict=True))
|
|
91
|
+
fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
92
|
+
info = os.fstat(fd)
|
|
93
|
+
require(stat.S_ISREG(info.st_mode) and not info.st_mode & 0o022 and 0 < info.st_size <= 16384,
|
|
94
|
+
'INVALID_SESSION', 'Cloud Shell TLS 憑證不存在或無效,請重新開啟 Shell。')
|
|
95
|
+
data = os.read(fd, 16385)
|
|
96
|
+
require(0 < len(data) <= 16384, 'INVALID_SESSION', 'Cloud Shell TLS 憑證大小無效。')
|
|
97
|
+
content = data.decode('ascii')
|
|
98
|
+
ssl.PEM_cert_to_DER_cert(content)
|
|
99
|
+
return content
|
|
100
|
+
except (OSError, ValueError, UnicodeError):
|
|
101
|
+
raise FufuError('INVALID_SESSION', 'Cloud Shell TLS 憑證不存在或無效,請重新開啟 Shell。') from None
|
|
102
|
+
finally:
|
|
103
|
+
if fd is not None:
|
|
104
|
+
os.close(fd)
|
|
105
|
+
|
|
106
|
+
|
|
75
107
|
def kubernetes_transport(api, name, who):
|
|
76
108
|
endpoint = api.endpoint
|
|
77
109
|
cluster = {}
|
|
@@ -79,13 +111,7 @@ def kubernetes_transport(api, name, who):
|
|
|
79
111
|
require(os.environ.get('FUFU_SESSION_FILE') == '/run/fufu/session.json',
|
|
80
112
|
'INVALID_SESSION', 'Cloud Shell 工作階段來源不符。')
|
|
81
113
|
certificate = Path('/run/fufu/kubernetes-ca.crt')
|
|
82
|
-
|
|
83
|
-
'INVALID_SESSION', 'Cloud Shell TLS 憑證不存在或無效,請重新開啟 Shell。')
|
|
84
|
-
content = certificate.read_text(encoding='ascii')
|
|
85
|
-
try:
|
|
86
|
-
ssl.PEM_cert_to_DER_cert(content)
|
|
87
|
-
except ValueError:
|
|
88
|
-
raise FufuError('INVALID_SESSION', 'Cloud Shell TLS 憑證無效。') from None
|
|
114
|
+
content = read_shell_certificate(certificate)
|
|
89
115
|
endpoint = 'https://127.0.0.1:8788'
|
|
90
116
|
cluster['certificate-authority-data'] = base64.b64encode(content.encode('ascii')).decode('ascii')
|
|
91
117
|
cluster['server'] = endpoint + f'/api/cloud/{api.provider}/infrastructure/clusters/{segment(name)}/kubernetes'
|
|
@@ -179,6 +205,7 @@ def gcp_command(argv, Parser):
|
|
|
179
205
|
if kind == 'vm':
|
|
180
206
|
q.add_argument('--machine-type', default='e2-small'); q.add_argument('--image-family', default='debian-12')
|
|
181
207
|
q.add_argument('--image-project', default='debian-cloud'); q.add_argument('--boot-disk-size', default='20GB')
|
|
208
|
+
q.add_argument('--accelerator'); q.add_argument('--maintenance-policy', choices=['MIGRATE', 'TERMINATE'], default='MIGRATE')
|
|
182
209
|
else:
|
|
183
210
|
q.add_argument('--cluster-version', default='1.36')
|
|
184
211
|
a = p.parse_args(tail)
|
|
@@ -197,12 +224,20 @@ def gcp_command(argv, Parser):
|
|
|
197
224
|
who = api.whoami()
|
|
198
225
|
spec = {'location': a.zone or a.location or who.get('region')}
|
|
199
226
|
if kind == 'vm':
|
|
200
|
-
require(a.machine_type in VM_TYPES and a.machine_type.startswith('e2-') and a.image_family == 'debian-12' and a.image_project == 'debian-cloud',
|
|
227
|
+
require(a.machine_type in VM_TYPES and a.machine_type.startswith(('e2-', 'n1-')) and a.image_family == 'debian-12' and a.image_project == 'debian-cloud',
|
|
201
228
|
'UNSUPPORTED_CONFIG', '目前提供已驗證的 Debian 12 映像與列明的 VM 規格。')
|
|
202
229
|
match = re.fullmatch(r'(\d+)(?:GB|GiB)?', a.boot_disk_size, re.I)
|
|
203
230
|
require(match is not None, 'INVALID_CONFIG', '磁碟大小須為整數 GB。')
|
|
204
231
|
cpu, memory = VM_TYPES[a.machine_type]
|
|
205
232
|
spec.update(cpu=cpu, memoryGiB=memory, machineType=a.machine_type, diskGiB=int(match[1]), image=a.image_family)
|
|
233
|
+
if a.accelerator:
|
|
234
|
+
parts = a.accelerator.split(',')
|
|
235
|
+
require(len(parts) == 2 and all('=' in p for p in parts), 'INVALID_CONFIG', 'Accelerator 格式為 type=nvidia-geforce-rtx-3060,count=1。')
|
|
236
|
+
gpu = dict(p.split('=', 1) for p in parts)
|
|
237
|
+
require(gpu == {'type': 'nvidia-geforce-rtx-3060', 'count': '1'}, 'UNSUPPORTED_CONFIG', '目前 GPU VM 僅開放已驗證的 RTX 3060;此型號為 FUFU 硬體擴充。')
|
|
238
|
+
require(a.machine_type.startswith('n1-') and a.maintenance_policy == 'TERMINATE', 'INVALID_CONFIG',
|
|
239
|
+
'GPU VM 請使用 n1-standard-4 或 n1-standard-8,並指定 --maintenance-policy=TERMINATE。')
|
|
240
|
+
spec['gpu'] = {'type': gpu['type'], 'count': 1}
|
|
206
241
|
else: spec['version'] = a.cluster_version
|
|
207
242
|
value = api.request('POST', route(kind, a.name), {'spec': spec})
|
|
208
243
|
elif a.action == 'delete': value = api.request('DELETE', route(kind, a.name))
|
|
@@ -226,6 +261,7 @@ def aws_command(argv, Parser):
|
|
|
226
261
|
else:
|
|
227
262
|
q = actions.add_parser('run-instances'); q.add_argument('--image-id', required=True); q.add_argument('--instance-type', default='t3.small')
|
|
228
263
|
q.add_argument('--count', type=int, default=1); q.add_argument('--tag-specifications')
|
|
264
|
+
q.add_argument('--placement')
|
|
229
265
|
for action in ['describe-instances', 'start-instances', 'stop-instances', 'terminate-instances']:
|
|
230
266
|
q = actions.add_parser(action); q.add_argument('--instance-ids', nargs='+', required=action != 'describe-instances')
|
|
231
267
|
q = actions.add_parser('wait'); q.add_argument('waiter', choices=['instance-running', 'instance-stopped', 'instance-terminated']); q.add_argument('--instance-ids', nargs='+', required=True)
|
|
@@ -242,7 +278,8 @@ def aws_command(argv, Parser):
|
|
|
242
278
|
else: value = api.request('GET', route(kind, a.name))
|
|
243
279
|
return {'cluster': aws_shape(value, kind, who)}
|
|
244
280
|
if a.action == 'run-instances':
|
|
245
|
-
|
|
281
|
+
gpu = a.instance_type == 'fufu.rtx3060.xlarge'
|
|
282
|
+
require(a.count == 1 and a.image_id == ('fufu-debian-12-gpu' if gpu else 'fufu-debian-12') and a.instance_type in VM_TYPES and not a.instance_type.startswith(('e2-', 'n1-')), 'UNSUPPORTED_CONFIG',
|
|
246
283
|
'目前使用 fufu-debian-12,且每次建立一台 VM;不會將官方 AMI 冒充為本地映像。')
|
|
247
284
|
name = 'vm-' + uuid.uuid4().hex[:12]
|
|
248
285
|
if a.tag_specifications:
|
|
@@ -251,7 +288,19 @@ def aws_command(argv, Parser):
|
|
|
251
288
|
and len(tags[0].get('Tags', [])) == 1 and tags[0]['Tags'][0].get('Key') == 'Name', 'UNSUPPORTED_CONFIG', '目前只接受一個 Name 標籤。')
|
|
252
289
|
name = tags[0]['Tags'][0]['Value']
|
|
253
290
|
cpu, memory = VM_TYPES[a.instance_type]
|
|
254
|
-
|
|
291
|
+
location = who['region']
|
|
292
|
+
if a.placement:
|
|
293
|
+
if a.placement.startswith('{'):
|
|
294
|
+
placement = json.loads(a.placement)
|
|
295
|
+
else:
|
|
296
|
+
parts = a.placement.split(',')
|
|
297
|
+
require(len(parts) == 1 and all('=' in p for p in parts), 'UNSUPPORTED_CONFIG', 'Placement 格式為 AvailabilityZone=fufu-m1 或 AvailabilityZone=fufu-m6。')
|
|
298
|
+
placement = dict(p.split('=', 1) for p in parts)
|
|
299
|
+
require(type(placement) is dict and set(placement) == {'AvailabilityZone'} and placement['AvailabilityZone'] in {'fufu-m1', 'fufu-m6'},
|
|
300
|
+
'UNSUPPORTED_CONFIG', '目前 placement 僅支援已配置的 FUFU AvailabilityZone。')
|
|
301
|
+
location = placement['AvailabilityZone']
|
|
302
|
+
api.request('POST', route(kind, name), {'spec': {'image': 'debian-12', 'machineType': a.instance_type, 'cpu': cpu, 'memoryGiB': memory, 'location': location,
|
|
303
|
+
**({'gpu': {'type': 'nvidia-geforce-rtx-3060', 'count': 1}, 'diskGiB': 20} if gpu else {})}})
|
|
255
304
|
value = api.request('GET', route(kind, name))
|
|
256
305
|
return {'Instances': [aws_shape(value, kind, who)]}
|
|
257
306
|
rows = api.request('GET', route(kind))['items']
|
|
@@ -153,6 +153,35 @@ def access_secret_version(name):
|
|
|
153
153
|
|
|
154
154
|
|
|
155
155
|
_installed = False
|
|
156
|
+
_pending_import = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _install_when_sdk_imports():
|
|
160
|
+
"""Patch under the SDK module import lock, before application code can use it."""
|
|
161
|
+
global _pending_import
|
|
162
|
+
if _pending_import is not None: return
|
|
163
|
+
import sys
|
|
164
|
+
from importlib.machinery import PathFinder
|
|
165
|
+
|
|
166
|
+
class Finder:
|
|
167
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
168
|
+
if fullname != 'botocore.session': return None
|
|
169
|
+
spec = PathFinder.find_spec(fullname, path, target)
|
|
170
|
+
if spec is None or spec.loader is None: return None
|
|
171
|
+
original = spec.loader
|
|
172
|
+
class Loader:
|
|
173
|
+
def create_module(self, spec):
|
|
174
|
+
return original.create_module(spec) if hasattr(original, 'create_module') else None
|
|
175
|
+
def exec_module(self, module):
|
|
176
|
+
original.exec_module(module)
|
|
177
|
+
install()
|
|
178
|
+
if finder in sys.meta_path: sys.meta_path.remove(finder)
|
|
179
|
+
def __getattr__(self, key): return getattr(original, key)
|
|
180
|
+
spec.loader = Loader()
|
|
181
|
+
return spec
|
|
182
|
+
finder = Finder()
|
|
183
|
+
_pending_import = finder
|
|
184
|
+
sys.meta_path.insert(0, finder)
|
|
156
185
|
|
|
157
186
|
|
|
158
187
|
def modeled_response(value, shape):
|
|
@@ -177,14 +206,32 @@ def install():
|
|
|
177
206
|
global _installed
|
|
178
207
|
if _installed or not os.environ.get('FUFU_RUNTIME_ENDPOINT'):
|
|
179
208
|
return
|
|
180
|
-
import
|
|
209
|
+
import sys
|
|
210
|
+
if 'botocore.session' not in sys.modules:
|
|
211
|
+
_install_when_sdk_imports()
|
|
212
|
+
return
|
|
213
|
+
session_module = sys.modules['botocore.session']
|
|
181
214
|
from botocore.awsrequest import AWSResponse
|
|
182
215
|
from botocore.response import StreamingBody
|
|
183
|
-
original =
|
|
216
|
+
original = session_module.Session.create_client
|
|
184
217
|
|
|
185
218
|
def create_client(session, service_name, *args, **kwargs):
|
|
186
219
|
if service_name not in {'dynamodb', 's3', 'sqs', 'ssm', 'secretsmanager', 'logs', 'lambda'}:
|
|
187
220
|
raise FufuCapabilityError('FUFU SDK service is not implemented: ' + service_name)
|
|
221
|
+
if service_name in {'s3', 'logs'} and os.environ.get('FUFU_AWS_SDK_TRANSPORT') == 'native-v1':
|
|
222
|
+
# Preserve the official serializer, signer, HTTP transport and paginator.
|
|
223
|
+
# The control plane injects credentials scoped to this live deployment.
|
|
224
|
+
from botocore.config import Config
|
|
225
|
+
endpoint = os.environ.get('FUFU_RUNTIME_ENDPOINT', '')
|
|
226
|
+
if endpoint != 'http://fufu-api:8080/api/runtime/aws':
|
|
227
|
+
raise FufuCapabilityError('Native AWS SDK requires the managed workload endpoint')
|
|
228
|
+
kwargs['endpoint_url'] = endpoint + '/sdk/' + service_name
|
|
229
|
+
kwargs['aws_access_key_id'] = os.environ['AWS_ACCESS_KEY_ID']
|
|
230
|
+
kwargs['aws_secret_access_key'] = os.environ['AWS_SECRET_ACCESS_KEY']
|
|
231
|
+
kwargs['aws_session_token'] = os.environ['AWS_SESSION_TOKEN']
|
|
232
|
+
config = kwargs.get('config') or Config()
|
|
233
|
+
kwargs['config'] = config.merge(Config(s3={'addressing_style': 'path'}, request_checksum_calculation='when_required'))
|
|
234
|
+
return original(session, service_name, *args, **kwargs)
|
|
188
235
|
kwargs['aws_access_key_id'] = 'FUFU_WORKLOAD'
|
|
189
236
|
kwargs['aws_secret_access_key'] = 'fufu-transport-does-not-use-sigv4'
|
|
190
237
|
kwargs['endpoint_url'] = 'http://fufu-api:8080'
|
|
@@ -219,5 +266,5 @@ def install():
|
|
|
219
266
|
client.meta.events.register('before-call', call)
|
|
220
267
|
return client
|
|
221
268
|
|
|
222
|
-
|
|
269
|
+
session_module.Session.create_client = create_client
|
|
223
270
|
_installed = True
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Native high-level S3 output; JSON callers use s3api."""
|
|
2
|
+
import mimetypes
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from .api_backend import ApiClient
|
|
10
|
+
from .cloud_data import s3_path
|
|
11
|
+
from .errors import require
|
|
12
|
+
from .object_transfer import download_file, upload_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def timestamp(value):
|
|
16
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).astimezone().strftime('%Y-%m-%d %H:%M:%S')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def size_text(size, human=False):
|
|
20
|
+
if not human: return str(size)
|
|
21
|
+
for unit in ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']:
|
|
22
|
+
if size < 1024 or unit == 'PiB': return f'{size:.1f} {unit}' if unit != 'Bytes' else f'{size} Bytes'
|
|
23
|
+
size /= 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_pages(native, params):
|
|
27
|
+
params = dict(params); seen = set(); deadline = time.monotonic()+300
|
|
28
|
+
while True:
|
|
29
|
+
require(time.monotonic() < deadline, 'LIST_TIMEOUT', 'S3 清單超過五分鐘;請縮小 prefix。')
|
|
30
|
+
page = native('ListObjectsV2', params)
|
|
31
|
+
yield page
|
|
32
|
+
if not page.get('IsTruncated'): return
|
|
33
|
+
token = page.get('NextContinuationToken')
|
|
34
|
+
require(isinstance(token, str) and token and token not in seen, 'INVALID_PAGINATION', 'S3 分頁回應無效;未將部分清單視為完整結果。')
|
|
35
|
+
seen.add(token); params['ContinuationToken'] = token
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def command(argv, Parser):
|
|
39
|
+
parser = Parser(prog='aws-fufu s3', description='S3 ls、單檔 cp、mb/rb/rm;recursive 僅支援清單。同步、遞迴傳輸與跨 bucket copy 尚未支援。')
|
|
40
|
+
root = parser.add_subparsers(dest='action', required=True)
|
|
41
|
+
ls = root.add_parser('ls'); ls.add_argument('url', nargs='?')
|
|
42
|
+
ls.add_argument('--recursive', action='store_true'); ls.add_argument('--human-readable', action='store_true')
|
|
43
|
+
ls.add_argument('--summarize', action='store_true'); ls.add_argument('--no-paginate', action='store_true')
|
|
44
|
+
ls.add_argument('--page-size', type=int, default=1000)
|
|
45
|
+
for verb in ['mb', 'rb', 'rm', 'cp']:
|
|
46
|
+
p = root.add_parser(verb)
|
|
47
|
+
if verb == 'cp':
|
|
48
|
+
p.add_argument('source'); p.add_argument('destination'); p.add_argument('--content-type')
|
|
49
|
+
p.add_argument('--no-progress', action='store_true')
|
|
50
|
+
else: p.add_argument('url')
|
|
51
|
+
if verb in ['cp', 'rm']:
|
|
52
|
+
p.add_argument('--quiet', action='store_true'); p.add_argument('--only-show-errors', action='store_true')
|
|
53
|
+
p.add_argument('--dryrun', action='store_true')
|
|
54
|
+
args = parser.parse_args(argv)
|
|
55
|
+
if args.action == 'ls': require(1 <= args.page_size <= 1000, 'INVALID_INPUT', 'page-size 必須為 1 到 1000。')
|
|
56
|
+
downloading = args.action == 'cp' and args.source.startswith('s3://')
|
|
57
|
+
if args.action == 'cp':
|
|
58
|
+
require(downloading != args.destination.startswith('s3://'), 'CAPABILITY_NOT_SUPPORTED', 'cp 目前支援本機與 S3 單檔傳輸;跨 bucket copy 尚未支援。')
|
|
59
|
+
with ApiClient('aws', read_only=args.action == 'ls' or downloading or getattr(args, 'dryrun', False)) as api:
|
|
60
|
+
native = lambda op, params: api.request('POST', '/control/native', {'service': 's3', 'operation': op, 'params': params})
|
|
61
|
+
if args.action == 'ls' and not args.url:
|
|
62
|
+
for bucket in native('ListBuckets', {}).get('Buckets', []):
|
|
63
|
+
print(timestamp(bucket['CreationDate']) + ' ' + bucket['Name'])
|
|
64
|
+
return
|
|
65
|
+
url = args.source if downloading else args.destination if args.action == 'cp' else args.url
|
|
66
|
+
_, key = s3_path(url); bucket = urlsplit(url).netloc
|
|
67
|
+
if args.action == 'ls':
|
|
68
|
+
params = {'Bucket': bucket, 'Prefix': key, 'MaxKeys': args.page_size}
|
|
69
|
+
if not args.recursive: params['Delimiter'] = '/'
|
|
70
|
+
count = total = 0
|
|
71
|
+
for page in list_pages(native, params):
|
|
72
|
+
for prefix in page.get('CommonPrefixes', []): print(' '*27 + 'PRE ' + prefix['Prefix'].rstrip('/').rsplit('/', 1)[-1] + '/')
|
|
73
|
+
for obj in page.get('Contents', []):
|
|
74
|
+
name = obj['Key'] if args.recursive else obj['Key'].rsplit('/', 1)[-1]
|
|
75
|
+
print(f"{timestamp(obj['LastModified'])} {size_text(obj['Size'], args.human_readable):>10} {name}")
|
|
76
|
+
count += 1; total += obj['Size']
|
|
77
|
+
if args.summarize: print(f'\nTotal Objects: {count}\n Total Size: {size_text(total, args.human_readable)}')
|
|
78
|
+
return
|
|
79
|
+
quiet = getattr(args, 'quiet', False) or getattr(args, 'only_show_errors', False)
|
|
80
|
+
dryrun = getattr(args, 'dryrun', False)
|
|
81
|
+
message = ''
|
|
82
|
+
if args.action == 'cp':
|
|
83
|
+
if downloading:
|
|
84
|
+
require(bool(key), 'INVALID_RESOURCE', '下載必須指定 object key。')
|
|
85
|
+
destination = Path(args.destination)
|
|
86
|
+
if destination.is_dir(): destination /= key.rsplit('/', 1)[-1]
|
|
87
|
+
if not dryrun: download_file(api, destination, bucket, key, overwrite=True)
|
|
88
|
+
message = f'download: {args.source} to {destination}'
|
|
89
|
+
else:
|
|
90
|
+
require(Path(args.source).is_file(), 'INVALID_INPUT', '上傳來源必須是檔案。')
|
|
91
|
+
if not key or key.endswith('/'): key += Path(args.source).name
|
|
92
|
+
if not dryrun: upload_file(api, args.source, bucket, key, content_type=args.content_type or mimetypes.guess_type(args.source)[0] or 'application/octet-stream')
|
|
93
|
+
message = f'upload: {args.source} to s3://{bucket}/{key}'
|
|
94
|
+
elif args.action == 'rm':
|
|
95
|
+
require(bool(key), 'INVALID_RESOURCE', '刪除必須指定 object key;遞迴刪除尚未支援。')
|
|
96
|
+
if not dryrun: native('DeleteObject', {'Bucket': bucket, 'Key': key})
|
|
97
|
+
message = f'delete: {url}'
|
|
98
|
+
else:
|
|
99
|
+
require(not key, 'INVALID_RESOURCE', 'Bucket 操作不接受 object key。')
|
|
100
|
+
params = {'Bucket': bucket}
|
|
101
|
+
if args.action == 'mb':
|
|
102
|
+
region = api.whoami()['region']
|
|
103
|
+
if region != 'us-east-1': params['CreateBucketConfiguration'] = {'LocationConstraint': region}
|
|
104
|
+
native('CreateBucket' if args.action == 'mb' else 'DeleteBucket', params)
|
|
105
|
+
message = ('make_bucket: ' if args.action == 'mb' else 'remove_bucket: ') + bucket
|
|
106
|
+
if not quiet: sys.stdout.write(('(dryrun) ' if dryrun else '') + message + '\n')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""FUFU metering is a platform extension, not an invented native AWS/GCP command."""
|
|
2
|
+
import csv
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .api_backend import ApiClient
|
|
9
|
+
from .errors import require
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def csv_report(report):
|
|
13
|
+
output = io.StringIO(newline='')
|
|
14
|
+
columns = ['month', 'timezone', 'billingStatus', 'resourceId', 'name', 'observationPercent',
|
|
15
|
+
'cpuEnergyPercentOfObservedRunning', 'unobservedSeconds', 'cpuPackageKwh', 'gpuKwh', 'wholeHostAttributedKwh',
|
|
16
|
+
'vcpuHours', 'cpuCoreHours', 'memoryGiBHours', 'diskGiBHours', 'gpuHours', 'costStatus', 'flags',
|
|
17
|
+
'costEstimatesJson', 'ratePlansJson', 'missingRates']
|
|
18
|
+
writer = csv.DictWriter(output, fieldnames=columns)
|
|
19
|
+
writer.writeheader()
|
|
20
|
+
for item in report['items']:
|
|
21
|
+
row = {**report, **item, **item['measures'], **item['coverage'], 'flags': ';'.join(item['flags'])}
|
|
22
|
+
row.update(costEstimatesJson=json.dumps(item.get('costs', {}), sort_keys=True),
|
|
23
|
+
ratePlansJson=json.dumps(item.get('ratePlans', []), sort_keys=True),
|
|
24
|
+
missingRates=';'.join(item.get('missingRates', [])))
|
|
25
|
+
# Blank means unknown; do not convert absent measurements into zero.
|
|
26
|
+
selected = {k: '' if row.get(k) is None else str(row[k]) for k in columns}
|
|
27
|
+
for key, value in selected.items():
|
|
28
|
+
if value.lstrip().startswith(('=', '+', '-', '@')):
|
|
29
|
+
selected[key] = "'" + value
|
|
30
|
+
writer.writerow(selected)
|
|
31
|
+
return output.getvalue()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def command(argv, parser_class):
|
|
35
|
+
parser = parser_class(prog='fufu usage', description='逐台 VM 的月用量與電量分攤估算;不是正式帳單。')
|
|
36
|
+
sub = parser.add_subparsers(dest='action', required=True)
|
|
37
|
+
report = sub.add_parser('report')
|
|
38
|
+
report.add_argument('--provider', required=True, choices=['aws', 'gcp'])
|
|
39
|
+
# All supported months are 2000–2099, when Taipei uses fixed UTC+08:00.
|
|
40
|
+
report.add_argument('--month', default=datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m'), help='YYYY-MM;Asia/Taipei')
|
|
41
|
+
report.add_argument('--format', choices=['json', 'csv'], default='json')
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
require(bool(re.fullmatch(r'20\d{2}-(?:0[1-9]|1[0-2])', args.month)), 'INVALID_ARGUMENT', '月份必須是 YYYY-MM。')
|
|
44
|
+
with ApiClient(args.provider) as api:
|
|
45
|
+
value = api.request('GET', '/infrastructure/usage/monthly?month=' + args.month)
|
|
46
|
+
if args.format == 'csv':
|
|
47
|
+
return csv_report(value)
|
|
48
|
+
return value
|
fufu_cloud_cli-0.10.1/README.md → fufu_cloud_cli-0.11.0/src/fufu_cloud_cli.egg-info/PKG-INFO
RENAMED
|
@@ -1,9 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fufu-cloud-cli
|
|
3
|
+
Version: 0.11.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
|
+
Requires-Dist: jmespath==1.1.0
|
|
11
|
+
Requires-Dist: tomli==2.2.1; python_version < "3.11"
|
|
12
|
+
Provides-Extra: yaml
|
|
13
|
+
Requires-Dist: PyYAML==6.0.3; extra == "yaml"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: boto3==1.40.66; extra == "test"
|
|
16
|
+
Provides-Extra: terraform
|
|
17
|
+
Requires-Dist: python-hcl2==7.3.1; extra == "terraform"
|
|
18
|
+
Provides-Extra: gcp
|
|
19
|
+
Requires-Dist: google-cloud-bigquery==3.38.0; extra == "gcp"
|
|
20
|
+
Requires-Dist: google-cloud-tasks==2.20.0; extra == "gcp"
|
|
21
|
+
Requires-Dist: google-cloud-logging==3.12.1; extra == "gcp"
|
|
22
|
+
Requires-Dist: google-cloud-storage==3.4.1; extra == "gcp"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
1
25
|
# FUFU Cloud CLI
|
|
2
26
|
|
|
3
27
|
以六個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
|
|
4
28
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
5
29
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
6
30
|
|
|
31
|
+
0.11.0 新增獨占 GPU VM 的租用命令。需由平台啟用並驗證對應實體節點;
|
|
32
|
+
支援的 RTX 3060 為整卡直通至 KVM 客體,可執行 NVIDIA 驅動與 CUDA。
|
|
33
|
+
停止 VM 仍保留整卡,刪除後才釋放。GPU 使用中或已保留會明確拒絕,
|
|
34
|
+
不會自動中斷其他工作。CLI 不包含節點 SSH 金鑰、主機裝置管理程式或平台設定。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
gcloud-fufu compute instances create gpu-demo \
|
|
38
|
+
--zone=GPU_ZONE --machine-type=n1-standard-4 \
|
|
39
|
+
--accelerator=type=nvidia-geforce-rtx-3060,count=1 \
|
|
40
|
+
--maintenance-policy=TERMINATE
|
|
41
|
+
gcloud-fufu compute ssh gpu-demo --zone=GPU_ZONE --command='nvidia-smi'
|
|
42
|
+
gcloud-fufu compute instances delete gpu-demo --zone=GPU_ZONE
|
|
43
|
+
|
|
44
|
+
aws-fufu ec2 run-instances --image-id=fufu-debian-12-gpu \
|
|
45
|
+
--instance-type=fufu.rtx3060.xlarge --placement=AvailabilityZone=GPU_ZONE
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`GPU_ZONE` 使用平台 GPU 選單提供的區域。`nvidia-geforce-rtx-3060` 與
|
|
49
|
+
`fufu.rtx3060.xlarge` 是 FUFU 的硬體擴充名稱;遷移到官方雲端時需改為官方
|
|
50
|
+
提供的機型、加速器與區域。這不表示與官方 GPU 型號、HA、計費或 IAM 完全等價。
|
|
51
|
+
|
|
7
52
|
0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
|
|
8
53
|
S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
|
|
9
54
|
KMS、bucket IAM、GCS bucket labels 或 force destroy。
|
|
@@ -16,7 +61,7 @@ get-credentials/update-kubeconfig、waiter 與刪除。
|
|
|
16
61
|
取得憑證後可使用標準 kubectl 部署 Deployment、Service、Job 及受管 GPU 容器。
|
|
17
62
|
工作站需另安裝 kubectl;配套 Cloud Shell 已提供。
|
|
18
63
|
|
|
19
|
-
VM 尚無公開 IP、複寫儲存、live migration
|
|
64
|
+
VM 尚無公開 IP、複寫儲存、live migration 或 GPU 熱插拔;
|
|
20
65
|
叢集尚無 workload PVC、公開 LoadBalancer、互動式 exec/attach/port-forward。
|
|
21
66
|
這批新 VM/cluster API 尚未支援官方 Terraform/OpenTofu/Pulumi provider。
|
|
22
67
|
原有 firewall、global LB、Redis 等 typed configuration 仍明示 `MOCK ONLY`,
|
|
@@ -112,7 +157,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
112
157
|
## Cloudflare 前端部署與 R2
|
|
113
158
|
|
|
114
159
|
```bash
|
|
115
|
-
pip install 'fufu-cloud-cli==0.
|
|
160
|
+
pip install 'fufu-cloud-cli==0.11.0'
|
|
116
161
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
117
162
|
wrangler-fufu capabilities
|
|
118
163
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -17,6 +17,7 @@ src/fufu_cloud_cli/cloudflare.py
|
|
|
17
17
|
src/fufu_cloud_cli/control.py
|
|
18
18
|
src/fufu_cloud_cli/docker_backend.py
|
|
19
19
|
src/fufu_cloud_cli/errors.py
|
|
20
|
+
src/fufu_cloud_cli/gateway_contract.py
|
|
20
21
|
src/fufu_cloud_cli/gcloud_environment.py
|
|
21
22
|
src/fufu_cloud_cli/gcloud_output.py
|
|
22
23
|
src/fufu_cloud_cli/gcp_infra.py
|
|
@@ -36,8 +37,10 @@ src/fufu_cloud_cli/pulumi-endpoints.json
|
|
|
36
37
|
src/fufu_cloud_cli/pulumi_bridge.py
|
|
37
38
|
src/fufu_cloud_cli/remote_compute.py
|
|
38
39
|
src/fufu_cloud_cli/runtime_sdk.py
|
|
40
|
+
src/fufu_cloud_cli/s3_highlevel.py
|
|
39
41
|
src/fufu_cloud_cli/secrets.py
|
|
40
42
|
src/fufu_cloud_cli/serverless.py
|
|
43
|
+
src/fufu_cloud_cli/usage.py
|
|
41
44
|
src/fufu_cloud_cli.egg-info/PKG-INFO
|
|
42
45
|
src/fufu_cloud_cli.egg-info/SOURCES.txt
|
|
43
46
|
src/fufu_cloud_cli.egg-info/dependency_links.txt
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws_data.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/common.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/runner.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/storage.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|