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.
Files changed (66) hide show
  1. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/PKG-INFO +25 -3
  2. fufu_cloud_cli-0.10.1/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md +23 -25
  3. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/pyproject.toml +2 -2
  4. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/api_backend.py +16 -0
  6. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capabilities.json +4 -4
  7. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cli.py +7 -0
  8. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/docker_backend.py +24 -10
  9. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/gateway_contract.py +8 -0
  10. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_sdk.py +7 -0
  11. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/infrastructure.py +62 -13
  12. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime_sdk.py +50 -3
  13. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/s3_highlevel.py +106 -0
  14. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/usage.py +48 -0
  15. fufu_cloud_cli-0.10.1/README.md → fufu_cloud_cli-0.11.0/src/fufu_cloud_cli.egg-info/PKG-INFO +47 -2
  16. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +3 -0
  17. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
  18. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/LICENSE +0 -0
  19. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/MANIFEST.in +0 -0
  20. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/setup.cfg +0 -0
  21. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__main__.py +0 -0
  22. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_errors.py +0 -0
  23. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_lambda.py +0 -0
  24. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_output.py +0 -0
  25. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/bigquery.py +0 -0
  26. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capacity.py +0 -0
  27. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
  28. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
  29. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/control.py +0 -0
  30. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/errors.py +0 -0
  31. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
  32. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_output.py +0 -0
  33. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_infra.py +0 -0
  34. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcs.py +0 -0
  35. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
  36. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/iac_state.py +0 -0
  37. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  38. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/limits.py +0 -0
  39. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/manifest.py +0 -0
  40. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
  41. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/observability.py +0 -0
  42. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/public_access.py +0 -0
  43. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pubsub.py +0 -0
  44. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  45. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
  46. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/remote_compute.py +0 -0
  47. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  48. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  49. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  50. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/secrets.py +0 -0
  51. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/serverless.py +0 -0
  52. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  53. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  54. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  55. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  56. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
  57. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  58. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  59. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
  60. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/storage.py +0 -0
  61. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +0 -0
  62. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/__init__.py +0 -0
  63. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/aws_text.py +0 -0
  64. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  65. {fufu_cloud_cli-0.10.1 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  66. {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.10.1
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 或自助 GPU passthrough;
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.10.1'
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
@@ -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 或自助 GPU passthrough;
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.10.1'
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.10.1"
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"
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.10.1"
3
+ __version__ = "0.11.0"
@@ -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", "no-self-service-GPU-passthrough", "no-official-IaC-provider"],
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", "no-self-service-GPU-passthrough", "no-official-IaC-provider"],
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'].get('com.docker.compose.service') == 'local-cloud-console',
288
- 'GATEWAY_UNAVAILABLE', '指定 gateway 必須是正在執行的 FUFU Console。')
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-opt", "max-size=1m", "--log-opt", "max-file=1",
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; no GPU passthrough.\n' if kind == 'vm' else
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
- require(certificate.is_file() and not certificate.is_symlink() and certificate.stat().st_size <= 16384,
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
- require(a.count == 1 and a.image_id == 'fufu-debian-12' and a.instance_type in VM_TYPES and not a.instance_type.startswith('e2-'), 'UNSUPPORTED_CONFIG',
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
- api.request('POST', route(kind, name), {'spec': {'image': 'debian-12', 'machineType': a.instance_type, 'cpu': cpu, 'memoryGiB': memory, 'location': who['region']}})
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 botocore.session
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 = botocore.session.Session.create_client
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
- botocore.session.Session.create_client = create_client
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
@@ -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 或自助 GPU passthrough;
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.10.1'
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
@@ -8,6 +8,7 @@ tomli==2.2.1
8
8
  google-cloud-bigquery==3.38.0
9
9
  google-cloud-tasks==2.20.0
10
10
  google-cloud-logging==3.12.1
11
+ google-cloud-storage==3.4.1
11
12
 
12
13
  [terraform]
13
14
  python-hcl2==7.3.1
File without changes