fufu-cloud-cli 0.10.0__tar.gz → 0.10.1__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 (63) hide show
  1. {fufu_cloud_cli-0.10.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.10.1}/PKG-INFO +6 -2
  2. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/README.md +5 -1
  3. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/aws_errors.py +1 -1
  6. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/capabilities.json +13 -7
  7. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/control.py +2 -1
  8. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/pulumi_bridge.py +5 -3
  9. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/common.py +5 -1
  10. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
  11. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/runner.py +42 -9
  12. fufu_cloud_cli-0.10.1/src/fufu_cloud_cli/terraform_bridge/storage.py +173 -0
  13. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1/src/fufu_cloud_cli.egg-info}/PKG-INFO +6 -2
  14. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
  15. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/LICENSE +0 -0
  16. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/MANIFEST.in +0 -0
  17. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/setup.cfg +0 -0
  18. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/__main__.py +0 -0
  19. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/api_backend.py +0 -0
  20. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/aws_lambda.py +0 -0
  21. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/aws_output.py +0 -0
  22. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/bigquery.py +0 -0
  23. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/capacity.py +0 -0
  24. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/cli.py +0 -0
  25. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/cloud_data.py +0 -0
  26. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/cloudflare.py +0 -0
  27. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/docker_backend.py +0 -0
  28. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/errors.py +0 -0
  29. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
  30. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/gcloud_output.py +0 -0
  31. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/gcp_infra.py +0 -0
  32. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/gcp_sdk.py +0 -0
  33. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/gcs.py +0 -0
  34. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/http_runtime.py +0 -0
  35. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/iac_state.py +0 -0
  36. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/infrastructure.py +0 -0
  37. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  38. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/limits.py +0 -0
  39. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/manifest.py +0 -0
  40. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/object_transfer.py +0 -0
  41. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/observability.py +0 -0
  42. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/public_access.py +0 -0
  43. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/pubsub.py +0 -0
  44. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  45. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/remote_compute.py +0 -0
  46. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  47. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  48. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  49. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  50. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/secrets.py +0 -0
  51. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/serverless.py +0 -0
  52. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  53. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  54. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  55. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  56. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  57. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +0 -0
  58. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/vendor/__init__.py +0 -0
  59. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli/vendor/aws_text.py +0 -0
  60. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  61. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  62. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  63. {fufu_cloud_cli-0.10.0 → fufu_cloud_cli-0.10.1}/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.10.1
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
@@ -27,6 +27,10 @@ Dynamic: license-file
27
27
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
28
28
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
29
29
 
30
+ 0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
31
+ S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
32
+ KMS、bucket IAM、GCS bucket labels 或 force destroy。
33
+
30
34
  0.10.0 新增 Compute Engine/EC2 VM 與 GKE/EKS 介面的實際運算命令。
31
35
  需要啟用相應 infrastructure backend 的 FUFU 服務端;VM 使用 KVM/KubeVirt,
32
36
  叢集使用獨立 Kubernetes API 與共用節點,均不是官方雲端資源。
@@ -131,7 +135,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
131
135
  ## Cloudflare 前端部署與 R2
132
136
 
133
137
  ```bash
134
- pip install 'fufu-cloud-cli==0.10.0'
138
+ pip install 'fufu-cloud-cli==0.10.1'
135
139
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
136
140
  wrangler-fufu capabilities
137
141
  wrangler-fufu pages project create frontend --production-branch main
@@ -4,6 +4,10 @@
4
4
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
5
5
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
6
6
 
7
+ 0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
8
+ S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
9
+ KMS、bucket IAM、GCS bucket labels 或 force destroy。
10
+
7
11
  0.10.0 新增 Compute Engine/EC2 VM 與 GKE/EKS 介面的實際運算命令。
8
12
  需要啟用相應 infrastructure backend 的 FUFU 服務端;VM 使用 KVM/KubeVirt,
9
13
  叢集使用獨立 Kubernetes API 與共用節點,均不是官方雲端資源。
@@ -108,7 +112,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
108
112
  ## Cloudflare 前端部署與 R2
109
113
 
110
114
  ```bash
111
- pip install 'fufu-cloud-cli==0.10.0'
115
+ pip install 'fufu-cloud-cli==0.10.1'
112
116
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
113
117
  wrangler-fufu capabilities
114
118
  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.10.1"
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"
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.10.1"
@@ -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",
@@ -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())
@@ -16,11 +16,12 @@ import yaml
16
16
 
17
17
  from .errors import FufuError, require
18
18
  from .terraform_bridge.common import Bridge
19
- from .terraform_bridge.runner import AWS_ENDPOINT_ALIASES, bind_context, child_environment, fcntl, private_directory, serve
19
+ from .terraform_bridge.runner import AWS_ENDPOINT_ALIASES, bind_context, child_environment, fcntl, private_directory, serve, s3_control_environment
20
20
 
21
21
  SDK_VERSIONS = {'pulumi': '3.261.0', 'aws': '7.45.0', 'gcp': '9.36.1'}
22
22
  RESOURCE_TYPES = {'aws:sqs/queue:Queue', 'aws:ssm/parameter:Parameter',
23
- 'gcp:pubsub/topic:Topic', 'gcp:pubsub/subscription:Subscription'}
23
+ 'gcp:pubsub/topic:Topic', 'gcp:pubsub/subscription:Subscription',
24
+ 'aws:s3/bucket:Bucket', 'gcp:storage/bucket:Bucket'}
24
25
 
25
26
 
26
27
  def read_mapping(path):
@@ -55,7 +56,7 @@ def provider_config(port, bridge, providers):
55
56
  keys = [key for key in keys if key not in aliases[1:]]
56
57
  values = {'endpoints': [{key: f'http://127.0.0.1:{port}/aws/{key}' for key in keys}],
57
58
  'region': bridge.identities['aws']['region'], 'maxRetries': 1,
58
- 'skipCredentialsValidation': True, 'skipMetadataApiCheck': True, 'skipRegionValidation': True}
59
+ 'skipCredentialsValidation': True, 'skipMetadataApiCheck': True, 'skipRegionValidation': True, 's3UsePathStyle': True}
59
60
  else:
60
61
  values = {}
61
62
  versions = {'iam': 'v1/', 'iam_beta': 'v1/', 'cloud_run_v2': 'v2/', 'cloud_run': 'v1/',
@@ -164,6 +165,7 @@ def command(argv, Parser):
164
165
  port = adapter_port(work)
165
166
  server, thread = serve(bridge, port, key, secret, token)
166
167
  env = child_environment(root, work, key, secret, token)
168
+ if 'aws' in args.providers: env.update(s3_control_environment(port))
167
169
  for key_name in ['NODE_OPTIONS', 'NODE_PATH']: env.pop(key_name, None)
168
170
  env.update(PULUMI_HOME=str(work / 'home'), PULUMI_BACKEND_URL=(work / 'backend').as_uri(),
169
171
  PULUMI_CONFIG_PASSPHRASE=passphrase, PULUMI_SKIP_UPDATE_CHECK='true', PULUMI_SKIP_CONFIRMATIONS='false',
@@ -49,13 +49,17 @@ def signing_valid(method, target, headers, body, access_key, secret):
49
49
  if headers.get('x-amz-content-sha256', digest) != digest:
50
50
  return None
51
51
  path, _, query = target.partition('?')
52
+ s3_path = path
52
53
  parts = []
53
54
  for part in path.split('/'):
54
55
  if part == '..':
55
56
  if parts: parts.pop()
56
57
  elif part and part != '.': parts.append(part)
57
58
  path = '/' + '/'.join(parts) + ('/' if path.endswith('/') and parts else '')
58
- canonical_path = quote(path, safe='/~')
59
+ # S3 and S3 Control use the transmitted path without normalization or
60
+ # double encoding. The official SDK signs encoded ARN path components this
61
+ # way; all services still require the actual payload digest and full HMAC.
62
+ canonical_path = s3_path if service == 's3' else quote(path, safe='/~')
59
63
  canonical_query = '&'.join(sorted(v if '=' in v else v + '=' for v in query.split('&') if v))
60
64
  canonical_headers = ''.join(n + ':' + ' '.join(headers[n].strip().split()) + '\n' for n in names)
61
65
  canonical = '\n'.join([method, canonical_path, canonical_query, canonical_headers, signed, digest])
@@ -151,6 +151,9 @@ def run(bridge, method, path, query, body):
151
151
 
152
152
  def dispatch(bridge, service, method, path, query, data):
153
153
  body = json.loads(data) if data else {}
154
+ if service == 'storage':
155
+ from .storage import google as bucket
156
+ return bucket(bridge, method, path, query, body)
154
157
  if service == 'pubsub':
155
158
  from .google_pubsub import dispatch as pubsub
156
159
  return pubsub(bridge, method, path, query, body)
@@ -24,14 +24,14 @@ from urllib.parse import parse_qs, urlsplit
24
24
  from ..errors import FufuError, require
25
25
  from ..aws_errors import AWS_ERRORS
26
26
  from .common import Bridge, signing_valid
27
- from . import aws, aws_data, google
27
+ from . import aws, aws_data, google, storage
28
28
  from .remote_state import StateProxy, state_name
29
29
 
30
30
  SUPPORTED_PROVIDERS = {'aws': '6.63.0', 'google': '8.1.0'}
31
31
  SUPPORTED_RESOURCES = {'aws_iam_role', 'aws_iam_role_policy', 'aws_lambda_function', 'aws_lambda_function_url',
32
32
  'aws_lambda_permission', 'google_service_account', 'google_cloud_run_v2_service',
33
33
  'google_cloud_run_v2_service_iam_member', 'aws_sqs_queue', 'aws_ssm_parameter',
34
- 'google_pubsub_topic', 'google_pubsub_subscription'}
34
+ 'google_pubsub_topic', 'google_pubsub_subscription', 'aws_s3_bucket', 'google_storage_bucket'}
35
35
  SUPPORTED_DATA = {'aws_caller_identity', 'aws_iam_policy_document', 'aws_region', 'aws_partition'}
36
36
  # Mutually exclusive aliases declared by the pinned official AWS provider.
37
37
  AWS_ENDPOINT_ALIASES = '''directoryservice ds;cognitoidentityprovider cognitoidp;amp prometheus prometheusservice;
@@ -69,6 +69,7 @@ def validate_provider_configuration(root, data_dir):
69
69
  for kind in declaration:
70
70
  require(not kind.startswith(('aws_', 'google_')) or kind in supported,
71
71
  'UNSUPPORTED_TERRAFORM_RESOURCE', '未支援的 Terraform 資源或 data source:' + kind)
72
+ if category == 'resource': storage.validate_hcl(kind, declaration[kind])
72
73
  providers = document.get('provider', [])
73
74
  if isinstance(providers, dict): providers = [providers]
74
75
  for entry in providers:
@@ -127,7 +128,14 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
127
128
  provider = 'state' if self.path.startswith('/state') else 'gcp' if self.path.startswith('/gcp/') else 'aws'
128
129
  headers = {k.lower(): v for k, v in self.headers.items()}
129
130
  try:
130
- require(headers.get('host') == f'127.0.0.1:{port}' and 'origin' not in headers and 'transfer-encoding' not in headers,
131
+ parsed = urlsplit(self.path)
132
+ # S3 Control injects the account into custom endpoint hosts. A
133
+ # closed loopback HTTP proxy handles only this exact target;
134
+ # it never resolves the hostname or forwards network traffic.
135
+ control_host = str(bridge.identities.get('aws', {}).get('accountId', '')) + f'.127.0.0.1:{port}'
136
+ s3_control = (parsed.scheme == 'http' and parsed.netloc == control_host and
137
+ parsed.path.startswith('/aws/s3control/') and headers.get('host') == control_host)
138
+ require((s3_control or not parsed.netloc and headers.get('host') == f'127.0.0.1:{port}') and 'origin' not in headers and 'transfer-encoding' not in headers,
131
139
  'AUTHENTICATION_FAILED', 'Terraform adapter 僅接受目前 loopback 工作階段。')
132
140
  if provider == 'state':
133
141
  require(state is not None, 'ACCESS_DENIED', '此工作階段沒有啟用遠端 state。')
@@ -141,16 +149,23 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
141
149
  body = self.rfile.read(length)
142
150
  require(len(body) == length, 'INVALID_TERRAFORM_REQUEST', 'Request 未完整傳送。')
143
151
  require(provider == 'state' or provider in bridge.clients, 'AUTHENTICATION_FAILED', '目前未啟用此 provider。')
144
- parsed = urlsplit(self.path)
145
152
  query = parse_qs(parsed.query, keep_blank_values=True)
146
153
  with bridge.lock:
147
154
  if provider == 'state':
148
155
  result = state.dispatch(self.command, self.path, headers, body)
149
156
  elif provider == 'aws':
150
- signed = signing_valid(self.command, self.path, headers, body, aws_key, aws_secret)
157
+ signed = signing_valid(self.command, parsed.path + ('?' + parsed.query if parsed.query else ''), headers, body, aws_key, aws_secret)
151
158
  require(signed, 'AUTHENTICATION_FAILED', 'Terraform AWS 簽章無效。')
152
159
  service, region = signed
153
160
  require(service in {'iam', 'sts'} or region == bridge.identities['aws']['region'], 'ACCESS_DENIED', 'AWS region 與 FUFU scope 不符。')
161
+ if s3_control:
162
+ require(service == 's3', 'AUTHENTICATION_FAILED', 'S3 Control 簽章服務無效。')
163
+ # The official provider explicitly falls back to
164
+ # Get/Put/DeleteBucketTagging on MethodNotAllowed.
165
+ # Actual access is checked by those native IAM APIs.
166
+ self.respond(405, storage.xml('ErrorResponse', {'Error': {'Code': 'MethodNotAllowed',
167
+ 'Message': 'Use bucket tagging APIs for this S3-compatible backend.'}}), {'Content-Type': 'application/xml'})
168
+ return
154
169
  prefix = '/aws/' + service
155
170
  queue_path = service == 'sqs' and re.fullmatch(r'/[0-9]{12}/[A-Za-z0-9_-]{1,80}', parsed.path)
156
171
  require(queue_path or parsed.path == '/aws' or parsed.path.startswith(prefix + '/') or parsed.path == prefix,
@@ -160,6 +175,7 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
160
175
  elif service == 'lambda': result = aws.lambda_api(bridge, self.command, route, query, body)
161
176
  elif service in {'sqs', 'ssm'}:
162
177
  result = aws_data.dispatch(bridge, service, self.command, route, headers, body, f'http://127.0.0.1:{port}')
178
+ elif service == 's3': result = storage.s3(bridge, self.command, route, query, headers, body)
163
179
  else: raise FufuError('UNSUPPORTED_TERRAFORM_OPERATION', '此 AWS API 尚未列入 Terraform 支援範圍。')
164
180
  else:
165
181
  require(hmac.compare_digest(headers.get('authorization', ''), 'Bearer ' + google_token), 'AUTHENTICATION_FAILED', 'Terraform Google 工作階段無效。')
@@ -174,7 +190,7 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
174
190
  expected = isinstance(error, FufuError)
175
191
  code = error.code if expected else 'TERRAFORM_ADAPTER_ERROR'
176
192
  message = error.message if expected else 'Terraform adapter 無法處理此請求;未輸出設定或 credential。'
177
- status = 404 if code in {'NOT_FOUND', 'RESOURCE_NOT_FOUND'} else 409 if code in {'RESOURCE_CONFLICT', 'ALREADY_EXISTS'} else 401 if code == 'AUTHENTICATION_FAILED' else 403 if code == 'ACCESS_DENIED' else 400 if expected else 500
193
+ status = 404 if code in {'NOT_FOUND', 'RESOURCE_NOT_FOUND'} else 409 if code in {'RESOURCE_CONFLICT', 'ALREADY_EXISTS'} else 401 if code == 'AUTHENTICATION_FAILED' else 403 if code == 'ACCESS_DENIED' else 412 if code == 'PRECONDITION_FAILED' else 400 if expected else 500
178
194
  bridge.calls.append({'provider': provider, 'error': code})
179
195
  if provider == 'aws':
180
196
  aws_code = {404: 'ResourceNotFoundException', 409: 'ResourceConflictException', 401: 'UnrecognizedClientException', 403: 'AccessDeniedException'}.get(status, 'InvalidParameterValueException')
@@ -185,7 +201,13 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
185
201
  # Providers use these codes to recognize absent resources on refresh/destroy.
186
202
  if code in AWS_ERRORS:
187
203
  aws_code = code
188
- if '/iam' in self.path:
204
+ if urlsplit(self.path).path.startswith(('/aws/s3/', '/aws/s3control/')) or self.path == '/aws/s3':
205
+ aws_code = {404: 'NoSuchBucket', 409: 'BucketAlreadyExists', 401: 'SignatureDoesNotMatch', 403: 'AccessDenied'}.get(status, 'InvalidRequest')
206
+ if code in AWS_ERRORS: aws_code = code
207
+ value = {'Code': aws_code, 'Message': message}
208
+ data = storage.xml('ErrorResponse', {'Error': value}) if '/aws/s3control/' in self.path else storage.xml('Error', value)
209
+ self.respond(status, data, {'Content-Type': 'application/xml'})
210
+ elif '/iam' in self.path:
189
211
  aws_code = {404: 'NoSuchEntity', 409: 'EntityAlreadyExists', 403: 'AccessDenied'}.get(status, 'InvalidInput')
190
212
  data = ('<ErrorResponse>' + aws.xml_value('Error', {'Type': 'Sender', 'Code': aws_code, 'Message': message}) + '</ErrorResponse>').encode()
191
213
  self.respond(status, data, {'Content-Type': 'text/xml'})
@@ -198,7 +220,7 @@ def serve(bridge, port, aws_key, aws_secret, google_token, state=None):
198
220
  error_headers['x-amzn-query-error'] = query_code + (';Receiver' if status >= 500 else ';Sender')
199
221
  self.respond(status, json.dumps({'__type': aws_code, 'message': message}).encode(), error_headers)
200
222
  else:
201
- google_status = {404: 'NOT_FOUND', 409: 'ALREADY_EXISTS', 401: 'UNAUTHENTICATED', 403: 'PERMISSION_DENIED'}.get(status, 'INVALID_ARGUMENT')
223
+ google_status = {404: 'NOT_FOUND', 409: 'ALREADY_EXISTS', 401: 'UNAUTHENTICATED', 403: 'PERMISSION_DENIED', 412: 'FAILED_PRECONDITION'}.get(status, 'INVALID_ARGUMENT')
202
224
  self.respond(status, json.dumps({'error': {'code': status, 'status': google_status, 'message': message}}).encode(), {})
203
225
  def respond(self, status, data, headers):
204
226
  try:
@@ -229,6 +251,16 @@ def child_environment(root, data_dir, key, secret, token):
229
251
  return env
230
252
 
231
253
 
254
+ def s3_control_environment(port):
255
+ """Route account-prefixed S3 Control requests to the closed local adapter.
256
+
257
+ All other non-loopback plaintext HTTP targets fail closed; HTTPS plugin
258
+ downloads are unaffected. No proxy forwarding or CONNECT is implemented.
259
+ """
260
+ return {'HTTP_PROXY': f'http://127.0.0.1:{port}', 'http_proxy': f'http://127.0.0.1:{port}',
261
+ 'NO_PROXY': '127.0.0.1,localhost', 'no_proxy': '127.0.0.1,localhost'}
262
+
263
+
232
264
  def private_directory(path):
233
265
  require(not path.is_symlink(), 'INVALID_TERRAFORM_STATE', 'FUFU state 目錄不可為符號連結。')
234
266
  path.mkdir(mode=0o700, exist_ok=True)
@@ -282,7 +314,7 @@ def endpoints(port, providers, schemas=None):
282
314
  aliases = [key for key in group.split() if key in keys]
283
315
  keys = [key for key in keys if key not in aliases[1:]]
284
316
  result['aws'] = {'skip_credentials_validation': True, 'skip_metadata_api_check': True, 'skip_region_validation': True,
285
- 'max_retries': 1, 'endpoints': [{key: base + '/aws/' + key for key in keys}]}
317
+ 'max_retries': 1, 's3_use_path_style': True, 'endpoints': [{key: base + '/aws/' + key for key in keys}]}
286
318
  else:
287
319
  keys = [key for key in schema.get('attributes', {}) if key.endswith('_custom_endpoint')] or ['iam_custom_endpoint', 'cloud_run_v2_custom_endpoint']
288
320
  versions = {'iam': 'v1/', 'iam_beta': 'v1/', 'cloud_run_v2': 'v2/', 'cloud_run': 'v1/', 'cloudfunctions2': 'v2/', 'cloud_functions': 'v1/', 'storage': 'storage/v1/', 'pubsub': 'v1/'}
@@ -332,6 +364,7 @@ def command(argv, Parser, engine='terraform'):
332
364
  key, secret, token = 'FUFUTF' + secrets.token_hex(10).upper(), secrets.token_urlsafe(32), secrets.token_urlsafe(32)
333
365
  port = 24000 + int(hashlib.sha256(str(root).encode()).hexdigest()[:8], 16) % 16000
334
366
  env = child_environment(root, data_dir, key, secret, token)
367
+ if 'aws' in args.providers: env.update(s3_control_environment(port))
335
368
  bridge = server = thread = owned_override = cli_config = lock_fd = child = None
336
369
  previous_signals = {}
337
370
  try:
@@ -0,0 +1,173 @@
1
+ """Bucket lifecycle over the existing tenant storage APIs.
2
+
3
+ No metadata-only fallback for encryption, ACLs, retention or object versions.
4
+ Unsupported S3 optional reads return the standard NotImplemented response;
5
+ the official provider explicitly handles this for third-party S3 backends.
6
+ """
7
+ import json
8
+ import re
9
+ import xml.etree.ElementTree as ET
10
+ from urllib.parse import quote, unquote
11
+
12
+ from ..errors import FufuError, require
13
+ from .common import only, default
14
+
15
+ S3_NS = 'http://s3.amazonaws.com/doc/2006-03-01/'
16
+ OPTIONAL_S3_READS = {'acl', 'policy', 'cors', 'website', 'versioning', 'accelerate',
17
+ 'requestPayment', 'logging', 'lifecycle', 'replication', 'encryption', 'object-lock'}
18
+ META_ARGUMENTS = {'count', 'for_each', 'depends_on', 'provider', 'lifecycle', 'timeouts'}
19
+
20
+
21
+ def validate_hcl(kind, configs):
22
+ """Reject unsupported bucket configuration before provider-side creation.
23
+
24
+ Runtime checks still validate the evaluated request. Client-only options
25
+ such as force_destroy must be explicit supported literals.
26
+ """
27
+ if kind not in {'aws_s3_bucket', 'google_storage_bucket'}: return
28
+ allowed = ({'bucket', 'bucket_prefix', 'tags', 'force_destroy', 'region'} if kind == 'aws_s3_bucket'
29
+ else {'name', 'location', 'project', 'storage_class', 'force_destroy'}) | META_ARGUMENTS
30
+ for config in configs.values():
31
+ only(config, allowed, kind)
32
+ require(config.get('force_destroy', False) is False, 'UNSUPPORTED_TERRAFORM_FIELD',
33
+ 'Bucket force_destroy 尚未支援;先明確清除物件,再刪除 bucket。')
34
+
35
+
36
+ def xml(root, values=None):
37
+ node = ET.Element(root, xmlns=S3_NS)
38
+ def add(parent, key, value):
39
+ for item in value if isinstance(value, list) else [value]:
40
+ child = ET.SubElement(parent, key)
41
+ if isinstance(item, dict):
42
+ for name, nested in item.items(): add(child, name, nested)
43
+ elif item is not None: child.text = str(item)
44
+ for key, value in (values or {}).items(): add(node, key, value)
45
+ return ET.tostring(node, encoding='utf-8', xml_declaration=True)
46
+
47
+
48
+ def s3_error(code, status=400):
49
+ return status, xml('Error', {'Code': code, 'Message': 'S3 operation unavailable in this FUFU bucket contract.'}), {'Content-Type': 'application/xml'}
50
+
51
+
52
+ def parse_xml(data, root):
53
+ require(len(data) <= 65536 and b'<!DOCTYPE' not in data.upper() and b'<!ENTITY' not in data.upper(),
54
+ 'INVALID_TERRAFORM_REQUEST', 'S3 bucket XML 格式或大小無效。')
55
+ try: node = ET.fromstring(data)
56
+ except ET.ParseError: raise FufuError('INVALID_TERRAFORM_REQUEST', 'S3 bucket XML 格式無效。') from None
57
+ for element in node.iter():
58
+ require(not element.attrib, 'UNSUPPORTED_TERRAFORM_FIELD', 'S3 bucket XML 屬性尚未支援。')
59
+ if element.tag.startswith('{'):
60
+ require(element.tag.startswith('{' + S3_NS + '}'), 'INVALID_TERRAFORM_REQUEST', 'S3 XML namespace 無效。')
61
+ element.tag = element.tag.split('}', 1)[1]
62
+ require(node.tag == root, 'INVALID_TERRAFORM_REQUEST', 'S3 bucket XML root 無效。')
63
+ return node
64
+
65
+
66
+ def s3(bridge, method, path, query, headers, data):
67
+ match = re.fullmatch(r'/([a-z0-9][a-z0-9.-]{1,61}[a-z0-9])/?', path)
68
+ require(match, 'UNSUPPORTED_TERRAFORM_OPERATION', 'S3 IaC 目前提供 bucket 生命週期。')
69
+ bucket = match[1]
70
+ require(not re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', bucket) and '..' not in bucket,
71
+ 'INVALID_TERRAFORM_REQUEST', 'S3 bucket 名稱無效。')
72
+ for key, value in headers.items():
73
+ if key.startswith(('x-amz-grant-', 'x-amz-object-lock-', 'x-amz-server-side-encryption')):
74
+ raise FufuError('UNSUPPORTED_TERRAFORM_FIELD', 'Bucket ACL、Object Lock 與加密尚未支援。')
75
+ require(headers.get('x-amz-acl') in (None, 'private') and headers.get('x-amz-bucket-namespace') in (None, 'global'),
76
+ 'UNSUPPORTED_TERRAFORM_FIELD', '目前只支援 private、global namespace bucket。')
77
+ require(headers.get('x-amz-expected-bucket-owner') in (None, bridge.identities['aws']['accountId']),
78
+ 'ACCESS_DENIED', 'Bucket owner 與目前租戶不符。')
79
+ params = dict(query); params.pop('x-id', None)
80
+ require(len(params) <= 1 and all(v == [''] for v in params.values()),
81
+ 'UNSUPPORTED_TERRAFORM_FIELD', 'S3 bucket query 尚未支援。')
82
+ operation = next(iter(params), '')
83
+ reply = {'Content-Type': 'application/xml', 'x-amz-bucket-region': bridge.identities['aws']['region']}
84
+ try:
85
+ if operation in OPTIONAL_S3_READS and method == 'GET':
86
+ # Check existence and authorization; do not report unverified config
87
+ # as an empty successful response to the official provider.
88
+ bridge.native('s3', 'HeadBucket', {'Bucket': bucket})
89
+ return s3_error('NotImplemented', 501)
90
+ if not operation and method == 'HEAD':
91
+ bridge.native('s3', 'HeadBucket', {'Bucket': bucket})
92
+ return 200, b'', reply
93
+ if not operation and method == 'PUT':
94
+ config = {}
95
+ if data:
96
+ document = parse_xml(data, 'CreateBucketConfiguration')
97
+ keys = [item.tag for item in document]
98
+ require(len(keys) == len(set(keys)) and set(keys) <= {'LocationConstraint', 'Tags'},
99
+ 'UNSUPPORTED_TERRAFORM_FIELD', 'S3 create bucket configuration 尚未支援。')
100
+ # The pinned provider retries without creation tags, then uses
101
+ # PutBucketTagging. Fail before creating any partial resource.
102
+ if 'Tags' in keys: return s3_error('UnsupportedArgument')
103
+ location = document.find('LocationConstraint')
104
+ if location is not None:
105
+ require(not list(location), 'INVALID_TERRAFORM_REQUEST', 'Bucket location 格式無效。')
106
+ config['CreateBucketConfiguration'] = {'LocationConstraint': location.text or ''}
107
+ bridge.native('s3', 'CreateBucket', {'Bucket': bucket, **config})
108
+ return 200, b'', {**reply, 'Location': '/' + bucket}
109
+ if not operation and method == 'DELETE':
110
+ bridge.native('s3', 'DeleteBucket', {'Bucket': bucket})
111
+ return 204, b'', reply
112
+ if operation == 'location' and method == 'GET':
113
+ value = bridge.native('s3', 'GetBucketLocation', {'Bucket': bucket}).get('LocationConstraint')
114
+ node = ET.Element('LocationConstraint', xmlns=S3_NS); node.text = value
115
+ return 200, ET.tostring(node, encoding='utf-8'), reply
116
+ if operation == 'tagging':
117
+ if method == 'GET':
118
+ value = bridge.native('s3', 'GetBucketTagging', {'Bucket': bucket})
119
+ return 200, xml('Tagging', {'TagSet': {'Tag': value.get('TagSet', [])}}), reply
120
+ if method == 'DELETE':
121
+ bridge.native('s3', 'DeleteBucketTagging', {'Bucket': bucket})
122
+ return 204, b'', reply
123
+ if method == 'PUT':
124
+ root = parse_xml(data, 'Tagging')
125
+ require(len(root) == 1 and root[0].tag == 'TagSet', 'INVALID_TERRAFORM_REQUEST', 'TagSet 必須唯一。')
126
+ tags = []
127
+ for tag in root[0]:
128
+ require(tag.tag == 'Tag' and sorted(c.tag for c in tag) == ['Key', 'Value'] and all(not list(c) for c in tag),
129
+ 'INVALID_TERRAFORM_REQUEST', 'S3 tag 必須包含 Key/Value。')
130
+ tags.append({c.tag: c.text or '' for c in tag})
131
+ bridge.native('s3', 'PutBucketTagging', {'Bucket': bucket, 'Tagging': {'TagSet': tags}})
132
+ return 200, b'', reply
133
+ raise FufuError('UNSUPPORTED_TERRAFORM_OPERATION', '此 S3 bucket 操作尚未支援。')
134
+ except FufuError as error:
135
+ codes = {'NoSuchBucket': 404, 'NoSuchTagSet': 404, 'NotFound': 404,
136
+ 'BucketNotEmpty': 409, 'BucketAlreadyExists': 409, 'BucketAlreadyOwnedByYou': 409}
137
+ if error.code in codes: return s3_error('NoSuchBucket' if error.code == 'NotFound' else error.code, codes[error.code])
138
+ if error.code in {'RESOURCE_NOT_FOUND', 'NOT_FOUND'}: return s3_error('NoSuchBucket', 404)
139
+ if error.code in {'ACCESS_DENIED', 'PERMISSION_DENIED'}: return s3_error('AccessDenied', 403)
140
+ raise
141
+
142
+
143
+ def google(bridge, method, path, query, body):
144
+ match = re.fullmatch(r'/storage/v1/b(?:/([^/]+))?/?', path)
145
+ require(match, 'UNSUPPORTED_TERRAFORM_OPERATION', 'GCS IaC 目前提供 bucket 生命週期。')
146
+ only(query, ['project', 'alt', 'prettyPrint'], 'GCS bucket query')
147
+ require(all(len(v) == 1 for v in query.values()), 'INVALID_TERRAFORM_REQUEST', '重複的 GCS query。')
148
+ if 'project' in query: bridge.project(query['project'][0])
149
+ default(query.get('alt', [None])[0], 'json', 'GCS response format')
150
+ name = unquote(match[1]) if match[1] else None
151
+ if name is not None:
152
+ require(re.fullmatch(r'[a-z0-9][a-z0-9._-]{1,220}[a-z0-9]', name), 'INVALID_TERRAFORM_REQUEST', 'Bucket name 無效。')
153
+ target = '/storage/buckets' + ('/' + quote(name, safe='') if name else '')
154
+ if method == 'POST' and name is None:
155
+ require('project' in query, 'INVALID_TERRAFORM_REQUEST', 'GCS bucket create 需要 project。')
156
+ value = dict(body)
157
+ for key, expected in {'labels': {}, 'lifecycle': {'rule': []}, 'versioning': {'enabled': False},
158
+ 'defaultEventBasedHold': False, 'billing': {'requesterPays': False}}.items():
159
+ default(value.pop(key, None), expected, 'GCS ' + key)
160
+ iam = only(value.pop('iamConfiguration', {}), ['uniformBucketLevelAccess', 'publicAccessPrevention'], 'GCS IAM')
161
+ default(iam.get('uniformBucketLevelAccess'), {'enabled': False}, 'GCS uniform bucket access')
162
+ default(iam.get('publicAccessPrevention'), 'inherited', 'GCS public access prevention')
163
+ only(value, ['name', 'location', 'storageClass'], 'GCS bucket')
164
+ result = bridge.request('gcp', 'POST', target, value)
165
+ elif name and method in {'GET', 'DELETE'}:
166
+ result = bridge.request('gcp', method, target)
167
+ if method == 'DELETE': return 204, b'', {}
168
+ else: raise FufuError('UNSUPPORTED_TERRAFORM_OPERATION', 'GCS bucket 的 labels/retention/IAM 更新尚未支援。')
169
+ # Stable resource identifiers; these URLs are identifiers, never a fallback
170
+ # destination for data traffic. Physical location comes from the backend.
171
+ result = {**result, 'kind': 'storage#bucket', 'id': result['name'],
172
+ 'selfLink': 'https://storage.googleapis.com/storage/v1/b/' + quote(result['name'], safe='')}
173
+ return 200, result, {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.10.0
3
+ Version: 0.10.1
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
@@ -27,6 +27,10 @@ Dynamic: license-file
27
27
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
28
28
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
29
29
 
30
+ 0.10.1 新增官方 Terraform/OpenTofu/Pulumi 的 S3/GCS bucket 生命週期與
31
+ S3 bucket tags。需配套 FUFU API;不支援 bucket versioning、retention、
32
+ KMS、bucket IAM、GCS bucket labels 或 force destroy。
33
+
30
34
  0.10.0 新增 Compute Engine/EC2 VM 與 GKE/EKS 介面的實際運算命令。
31
35
  需要啟用相應 infrastructure backend 的 FUFU 服務端;VM 使用 KVM/KubeVirt,
32
36
  叢集使用獨立 Kubernetes API 與共用節點,均不是官方雲端資源。
@@ -131,7 +135,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
131
135
  ## Cloudflare 前端部署與 R2
132
136
 
133
137
  ```bash
134
- pip install 'fufu-cloud-cli==0.10.0'
138
+ pip install 'fufu-cloud-cli==0.10.1'
135
139
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
136
140
  wrangler-fufu capabilities
137
141
  wrangler-fufu pages project create frontend --production-branch main
@@ -55,6 +55,7 @@ src/fufu_cloud_cli/terraform_bridge/google.py
55
55
  src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
56
56
  src/fufu_cloud_cli/terraform_bridge/remote_state.py
57
57
  src/fufu_cloud_cli/terraform_bridge/runner.py
58
+ src/fufu_cloud_cli/terraform_bridge/storage.py
58
59
  src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt
59
60
  src/fufu_cloud_cli/vendor/__init__.py
60
61
  src/fufu_cloud_cli/vendor/aws_text.py
File without changes