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.
Files changed (66) hide show
  1. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/PKG-INFO +29 -3
  2. fufu_cloud_cli-0.10.0/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.11.0/README.md +27 -25
  3. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/pyproject.toml +2 -2
  4. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/api_backend.py +16 -0
  6. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_errors.py +1 -1
  7. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capabilities.json +17 -11
  8. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cli.py +7 -0
  9. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/control.py +2 -1
  10. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/docker_backend.py +24 -10
  11. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/gateway_contract.py +8 -0
  12. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_sdk.py +7 -0
  13. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/infrastructure.py +62 -13
  14. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi_bridge.py +5 -3
  15. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime_sdk.py +50 -3
  16. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/s3_highlevel.py +106 -0
  17. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/common.py +5 -1
  18. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
  19. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +42 -9
  20. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/terraform_bridge/storage.py +173 -0
  21. fufu_cloud_cli-0.11.0/src/fufu_cloud_cli/usage.py +48 -0
  22. fufu_cloud_cli-0.10.0/README.md → fufu_cloud_cli-0.11.0/src/fufu_cloud_cli.egg-info/PKG-INFO +51 -2
  23. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +4 -0
  24. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
  25. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/LICENSE +0 -0
  26. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/MANIFEST.in +0 -0
  27. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/setup.cfg +0 -0
  28. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/__main__.py +0 -0
  29. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_lambda.py +0 -0
  30. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/aws_output.py +0 -0
  31. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/bigquery.py +0 -0
  32. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/capacity.py +0 -0
  33. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
  34. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
  35. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/errors.py +0 -0
  36. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
  37. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcloud_output.py +0 -0
  38. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcp_infra.py +0 -0
  39. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/gcs.py +0 -0
  40. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
  41. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/iac_state.py +0 -0
  42. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  43. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/limits.py +0 -0
  44. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/manifest.py +0 -0
  45. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
  46. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/observability.py +0 -0
  47. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/public_access.py +0 -0
  48. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pubsub.py +0 -0
  49. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  50. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/remote_compute.py +0 -0
  51. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  52. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  53. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  54. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/secrets.py +0 -0
  55. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/serverless.py +0 -0
  56. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  57. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  58. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  59. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  60. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  61. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +0 -0
  62. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/__init__.py +0 -0
  63. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli/vendor/aws_text.py +0 -0
  64. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  65. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.11.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  66. {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.10.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 或自助 GPU passthrough;
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.10.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
@@ -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 或自助 GPU passthrough;
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.10.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.10.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"
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.10.0"
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))
@@ -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.1", "opentofu": "1.12.6" },
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-Terraform-storage-or-Functions-Gen1-Gen2", "PubSub-requires-patched-emulator", "unsupported-APIs-and-fields-fail-explicitly"]
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-GCS-Terraform-or-Pulumi-resource-adapter"
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", "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"],
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", "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"],
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'].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']