fufu-cloud-cli 0.10.0__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.0 → fufu_cloud_cli-0.11.0}/PKG-INFO +29 -3
- fufu_cloud_cli-0.10.0/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md +27 -25
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/pyproject.toml +2 -2
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/api_backend.py +16 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_errors.py +1 -1
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capabilities.json +17 -11
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cli.py +7 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/control.py +2 -1
- {fufu_cloud_cli-0.10.0 → 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.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_sdk.py +7 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/infrastructure.py +62 -13
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi_bridge.py +5 -3
- {fufu_cloud_cli-0.10.0 → 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.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/common.py +5 -1
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +42 -9
- fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/terraform_bridge/storage.py +173 -0
- fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/usage.py +48 -0
- fufu_cloud_cli-0.10.0/README.md → fufu_cloud_cli-0.11.0/src/fufu_cloud_cli.egg-info/PKG-INFO +51 -2
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +4 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/LICENSE +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/setup.cfg +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_lambda.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_output.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/bigquery.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_output.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_infra.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcs.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/observability.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pubsub.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/remote_compute.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/__init__.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/aws_text.py +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.10.0 → 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,31 @@ 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
|
+
|
|
52
|
+
0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
|
|
53
|
+
S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
|
|
54
|
+
KMS、bucket IAM、GCS bucket labels 或 force destroy。
|
|
55
|
+
|
|
30
56
|
0.10.0 新增 Compute Engine/EC2 VM 與 GKE/EKS 介面的實際運算命令。
|
|
31
57
|
需要啟用相應 infrastructure backend 的 FUFU 服務端;VM 使用 KVM/KubeVirt,
|
|
32
58
|
叢集使用獨立 Kubernetes API 與共用節點,均不是官方雲端資源。
|
|
@@ -35,7 +61,7 @@ get-credentials/update-kubeconfig、waiter 與刪除。
|
|
|
35
61
|
取得憑證後可使用標準 kubectl 部署 Deployment、Service、Job 及受管 GPU 容器。
|
|
36
62
|
工作站需另安裝 kubectl;配套 Cloud Shell 已提供。
|
|
37
63
|
|
|
38
|
-
VM 尚無公開 IP、複寫儲存、live migration
|
|
64
|
+
VM 尚無公開 IP、複寫儲存、live migration 或 GPU 熱插拔;
|
|
39
65
|
叢集尚無 workload PVC、公開 LoadBalancer、互動式 exec/attach/port-forward。
|
|
40
66
|
這批新 VM/cluster API 尚未支援官方 Terraform/OpenTofu/Pulumi provider。
|
|
41
67
|
原有 firewall、global LB、Redis 等 typed configuration 仍明示 `MOCK ONLY`,
|
|
@@ -131,7 +157,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
131
157
|
## Cloudflare 前端部署與 R2
|
|
132
158
|
|
|
133
159
|
```bash
|
|
134
|
-
pip install 'fufu-cloud-cli==0.
|
|
160
|
+
pip install 'fufu-cloud-cli==0.11.0'
|
|
135
161
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
136
162
|
wrangler-fufu capabilities
|
|
137
163
|
wrangler-fufu pages project create frontend --production-branch main
|
fufu_cloud_cli-0.10.0/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md
RENAMED
|
@@ -1,32 +1,34 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: fufu-cloud-cli
|
|
3
|
-
Version: 0.10.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
|
-
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
|
+
|
|
28
|
+
0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
|
|
29
|
+
S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
|
|
30
|
+
KMS、bucket IAM、GCS bucket labels 或 force destroy。
|
|
31
|
+
|
|
30
32
|
0.10.0 新增 Compute Engine/EC2 VM 與 GKE/EKS 介面的實際運算命令。
|
|
31
33
|
需要啟用相應 infrastructure backend 的 FUFU 服務端;VM 使用 KVM/KubeVirt,
|
|
32
34
|
叢集使用獨立 Kubernetes API 與共用節點,均不是官方雲端資源。
|
|
@@ -35,7 +37,7 @@ get-credentials/update-kubeconfig、waiter 與刪除。
|
|
|
35
37
|
取得憑證後可使用標準 kubectl 部署 Deployment、Service、Job 及受管 GPU 容器。
|
|
36
38
|
工作站需另安裝 kubectl;配套 Cloud Shell 已提供。
|
|
37
39
|
|
|
38
|
-
VM 尚無公開 IP、複寫儲存、live migration
|
|
40
|
+
VM 尚無公開 IP、複寫儲存、live migration 或 GPU 熱插拔;
|
|
39
41
|
叢集尚無 workload PVC、公開 LoadBalancer、互動式 exec/attach/port-forward。
|
|
40
42
|
這批新 VM/cluster API 尚未支援官方 Terraform/OpenTofu/Pulumi provider。
|
|
41
43
|
原有 firewall、global LB、Redis 等 typed configuration 仍明示 `MOCK ONLY`,
|
|
@@ -131,7 +133,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
131
133
|
## Cloudflare 前端部署與 R2
|
|
132
134
|
|
|
133
135
|
```bash
|
|
134
|
-
pip install 'fufu-cloud-cli==0.
|
|
136
|
+
pip install 'fufu-cloud-cli==0.11.0'
|
|
135
137
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
136
138
|
wrangler-fufu capabilities
|
|
137
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))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Only documented provider error codes may cross the public transport boundary."""
|
|
2
2
|
AWS_ERRORS = frozenset('''ConditionalCheckFailedException ResourceNotFoundException ResourceInUseException ResourceAlreadyExistsException ValidationException
|
|
3
|
-
NoSuchKey NoSuchBucket NotFound NotModified ParameterNotFound ParameterAlreadyExists ParameterVersionNotFound QueueDoesNotExist
|
|
3
|
+
NoSuchKey NoSuchBucket NoSuchTagSet BucketAlreadyExists BucketAlreadyOwnedByYou BucketNotEmpty NotFound NotModified ParameterNotFound ParameterAlreadyExists ParameterVersionNotFound QueueDoesNotExist
|
|
4
4
|
PreconditionFailed InvalidRange InvalidParameterException InvalidParameterValue InvalidAttributeName InvalidAttributeValue QueueNameExists
|
|
5
5
|
PurgeQueueInProgress ReceiptHandleIsInvalid BatchEntryIdsNotDistinct EmptyBatchRequest BatchRequestTooLong InvalidBatchEntryId
|
|
6
6
|
TooManyEntriesInBatchRequest InvalidMessageContents AWS.SimpleQueueService.NonExistentQueue TooManyRequestsException InvalidRequestContentException'''.split())
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"backend": "api",
|
|
28
28
|
"compatibility": "official-provider-subset",
|
|
29
29
|
"providers": { "hashicorp/aws": "6.63.0", "hashicorp/google": "8.1.0" },
|
|
30
|
-
"resources": ["aws_iam_role", "aws_iam_role_policy", "aws_lambda_function", "aws_lambda_function_url", "aws_lambda_permission", "google_service_account", "google_cloud_run_v2_service", "google_cloud_run_v2_service_iam_member", "aws_sqs_queue", "aws_ssm_parameter", "google_pubsub_topic", "google_pubsub_subscription"],
|
|
30
|
+
"resources": ["aws_iam_role", "aws_iam_role_policy", "aws_lambda_function", "aws_lambda_function_url", "aws_lambda_permission", "google_service_account", "google_cloud_run_v2_service", "google_cloud_run_v2_service_iam_member", "aws_sqs_queue", "aws_ssm_parameter", "google_pubsub_topic", "google_pubsub_subscription", "aws_s3_bucket", "google_storage_bucket"],
|
|
31
31
|
"operations": ["init", "plan", "saved-plan-apply", "refresh", "update", "import", "destroy"],
|
|
32
|
-
"engines": { "terraform": "1.16.
|
|
32
|
+
"engines": { "terraform": "1.16.2", "opentofu": "1.12.6" },
|
|
33
33
|
"state": ["isolated-local", "tenant-remote-http", "context-binding"],
|
|
34
|
-
"limitations": ["Linux-amd64-verified", "terraform-extra-required", "root-default-providers-only", "OpenTofu-data-resources-verified-compute-not-retested", "no-full-cloud-IAM-or-runtime-equivalence", "no-
|
|
34
|
+
"limitations": ["Linux-amd64-verified", "terraform-extra-required", "root-default-providers-only", "OpenTofu-data-resources-verified-compute-not-retested", "no-full-cloud-IAM-or-runtime-equivalence", "no-VM-cluster-Functions-Gen1-Gen2-IaC", "storage-bucket-lifecycle-and-S3-tags-only", "no-force-destroy-retention-versioning-bucket-IAM-KMS", "PubSub-requires-patched-emulator", "unsupported-APIs-and-fields-fail-explicitly"]
|
|
35
35
|
},
|
|
36
36
|
"iac-remote-state": {
|
|
37
37
|
"status": "implemented", "backend": "api", "mode": "simplified",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"pulumi-data": {
|
|
42
42
|
"status": "implemented", "backend": "api", "compatibility": "official-provider-subset",
|
|
43
43
|
"providers": { "pulumi": "3.261.0", "aws": "7.45.0", "gcp": "9.36.1" },
|
|
44
|
-
"resources": ["aws:sqs/queue:Queue", "aws:ssm/parameter:Parameter", "gcp:pubsub/topic:Topic", "gcp:pubsub/subscription:Subscription"],
|
|
44
|
+
"resources": ["aws:sqs/queue:Queue", "aws:ssm/parameter:Parameter", "gcp:pubsub/topic:Topic", "gcp:pubsub/subscription:Subscription", "aws:s3/bucket:Bucket", "gcp:storage/bucket:Bucket"],
|
|
45
45
|
"operations": ["preview", "up", "refresh", "update", "import", "destroy"],
|
|
46
46
|
"limitations": ["Node.js-runtime", "default-providers", "separate-local-DIY-state", "external-passphrase-required", "secret-fields-encrypted-not-whole-state", "no-remote-backend", "no-compute-resource-acceptance", "trusted-programs-only"]
|
|
47
47
|
},
|
|
@@ -197,7 +197,8 @@
|
|
|
197
197
|
"metadata-describe-update-merge-delete",
|
|
198
198
|
"paginated-object-list",
|
|
199
199
|
"binary-cat",
|
|
200
|
-
"UTF8-preview"
|
|
200
|
+
"UTF8-preview",
|
|
201
|
+
"Terraform-OpenTofu-Pulumi-bucket-lifecycle"
|
|
201
202
|
],
|
|
202
203
|
"limitations": [
|
|
203
204
|
"object-5TiB-policy-subject-to-host-disk-capacity",
|
|
@@ -209,7 +210,8 @@
|
|
|
209
210
|
"single-writer-per-volume",
|
|
210
211
|
"no-signed-URLs",
|
|
211
212
|
"no-versioning-retention-ACL-KMS",
|
|
212
|
-
"no-
|
|
213
|
+
"no-bucket-labels-IAM-PATCH-force-destroy",
|
|
214
|
+
"bucket-current-tenant-location-STANDARD-only"
|
|
213
215
|
]
|
|
214
216
|
},
|
|
215
217
|
"azure-catalog-api": {
|
|
@@ -270,7 +272,11 @@
|
|
|
270
272
|
"rb",
|
|
271
273
|
"ls",
|
|
272
274
|
"cp-upload-download",
|
|
273
|
-
"rm"
|
|
275
|
+
"rm",
|
|
276
|
+
"get-bucket-tagging",
|
|
277
|
+
"put-bucket-tagging",
|
|
278
|
+
"delete-bucket-tagging",
|
|
279
|
+
"Terraform-OpenTofu-Pulumi-bucket-lifecycle"
|
|
274
280
|
],
|
|
275
281
|
"limitations": [
|
|
276
282
|
"object-10000-parts-of-up-to-5GiB-subject-to-host-disk-capacity",
|
|
@@ -538,9 +544,9 @@
|
|
|
538
544
|
"backend": "api",
|
|
539
545
|
"mode": "simplified",
|
|
540
546
|
"operations": [
|
|
541
|
-
"create-list-describe-delete", "stop-start", "guest-ssh-command"
|
|
547
|
+
"create-list-describe-delete", "stop-start", "guest-ssh-command", "exclusive-gpu-vm"
|
|
542
548
|
],
|
|
543
|
-
"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"],
|
|
544
550
|
"reason": "實際 KVM/KubeVirt VM、持久磁碟與 SSH 指令;原生風格指令子集,無官方雲端資源。"
|
|
545
551
|
},
|
|
546
552
|
"gcp-gke": {
|
|
@@ -642,8 +648,8 @@
|
|
|
642
648
|
"status": "implemented",
|
|
643
649
|
"backend": "api",
|
|
644
650
|
"mode": "simplified",
|
|
645
|
-
"operations": ["run-describe-terminate-instances", "stop-start", "instance-waiters"],
|
|
646
|
-
"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"],
|
|
647
653
|
"reason": "實際 KVM/KubeVirt VM;支援生命週期與 scoped instance ID,無官方 EC2 網路或完整 API。"
|
|
648
654
|
},
|
|
649
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
|
|
@@ -28,6 +28,7 @@ AWS_OPERATIONS = {
|
|
|
28
28
|
},
|
|
29
29
|
's3api': {
|
|
30
30
|
'CreateBucket': 'Bucket CreateBucketConfiguration', 'DeleteBucket': 'Bucket', 'ListBuckets': '', 'HeadBucket': 'Bucket', 'GetBucketLocation': 'Bucket',
|
|
31
|
+
'GetBucketTagging': 'Bucket', 'PutBucketTagging': 'Bucket Tagging', 'DeleteBucketTagging': 'Bucket',
|
|
31
32
|
'ListObjectsV2': 'Bucket Prefix MaxKeys ContinuationToken Delimiter StartAfter EncodingType', 'PutObject': 'Bucket Key Body ContentType Metadata',
|
|
32
33
|
'GetObject': 'Bucket Key ChecksumMode Range IfMatch IfNoneMatch', 'HeadObject': 'Bucket Key ChecksumMode IfMatch IfNoneMatch', 'DeleteObject': 'Bucket Key',
|
|
33
34
|
},
|
|
@@ -53,7 +54,7 @@ AWS_OPERATIONS = {
|
|
|
53
54
|
'PutRetentionPolicy': 'logGroupName retentionInDays', 'DeleteRetentionPolicy': 'logGroupName',
|
|
54
55
|
},
|
|
55
56
|
}
|
|
56
|
-
JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes Tags tags ParameterFilters Entries logEvents'.split())
|
|
57
|
+
JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes Tags tags Tagging ParameterFilters Entries logEvents'.split())
|
|
57
58
|
LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames MessageSystemAttributeNames TagKeys logStreamNames'.split())
|
|
58
59
|
BOOL_FIELDS = set('ConsistentRead ScanIndexForward WithDecryption Overwrite Enabled Recursive descending startFromHead'.split())
|
|
59
60
|
INT_FIELDS = set('Limit MaxKeys MaxResults MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds DelaySeconds BatchSize limit startTime endTime retentionInDays'.split())
|
|
@@ -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']
|