fufu-cloud-cli 0.9.5__tar.gz → 0.9.6__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 (59) hide show
  1. {fufu_cloud_cli-0.9.5/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.6}/PKG-INFO +11 -4
  2. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/README.md +9 -3
  3. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/pyproject.toml +3 -3
  4. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/api_backend.py +8 -0
  6. fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/aws_errors.py +6 -0
  7. fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/aws_output.py +65 -0
  8. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/capabilities.json +22 -6
  9. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cli.py +61 -11
  10. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/control.py +37 -12
  11. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/remote_compute.py +6 -2
  12. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime_sdk.py +44 -12
  13. fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +12 -0
  14. fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/__init__.py +1 -0
  15. fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/aws_text.py +117 -0
  16. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6/src/fufu_cloud_cli.egg-info}/PKG-INFO +11 -4
  17. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/SOURCES.txt +6 -1
  18. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
  19. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/LICENSE +0 -0
  20. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/MANIFEST.in +0 -0
  21. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/setup.cfg +0 -0
  22. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/__main__.py +0 -0
  23. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/bigquery.py +0 -0
  24. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/capacity.py +0 -0
  25. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cloud_data.py +0 -0
  26. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cloudflare.py +0 -0
  27. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/docker_backend.py +0 -0
  28. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/errors.py +0 -0
  29. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
  30. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcloud_output.py +0 -0
  31. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcp_infra.py +0 -0
  32. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcp_sdk.py +0 -0
  33. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcs.py +0 -0
  34. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/http_runtime.py +0 -0
  35. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/iac_state.py +0 -0
  36. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  37. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/limits.py +0 -0
  38. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/manifest.py +0 -0
  39. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/object_transfer.py +0 -0
  40. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/public_access.py +0 -0
  41. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pubsub.py +0 -0
  42. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  43. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
  44. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  45. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  46. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  47. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/secrets.py +0 -0
  48. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/serverless.py +0 -0
  49. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  50. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  51. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  52. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  53. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
  54. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  55. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  56. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
  57. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  58. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  59. {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/top_level.txt +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.9.5
3
+ Version: 0.9.6
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
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: PyYAML==6.0.3
10
+ Requires-Dist: jmespath==1.1.0
10
11
  Requires-Dist: tomli==2.2.1; python_version < "3.11"
11
12
  Provides-Extra: yaml
12
13
  Requires-Dist: PyYAML==6.0.3; extra == "yaml"
@@ -26,6 +27,11 @@ Dynamic: license-file
26
27
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
27
28
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
28
29
 
30
+ 0.9.6 新增 AWS Lambda Event/DryRun、configuration 更新、JMESPath `--query`、
31
+ json/text/yaml 輸出、SQS FIFO/batch、SSM path/history、DynamoDB Binary
32
+ 及 CloudWatch Logs。需要配套後端;Event 202 只表示持久化接受,並非 handler
33
+ 執行完成。仍無完整 AWS CLI 自動分頁、table、Layers/Image/aliases 或全量 IAM。
34
+
29
35
  0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
30
36
  支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
31
37
  insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
@@ -65,7 +71,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
65
71
  ## Cloudflare 前端部署與 R2
66
72
 
67
73
  ```bash
68
- pip install 'fufu-cloud-cli==0.9.5'
74
+ pip install 'fufu-cloud-cli==0.9.6'
69
75
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
70
76
  wrangler-fufu capabilities
71
77
  wrangler-fufu pages project create frontend --production-branch main
@@ -236,10 +242,11 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
236
242
  | --- | --- |
237
243
  | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
238
244
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
239
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
245
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、configuration、RequestResponse/Event/DryRun、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 6 MiB/Event 1 MiB;Event 為單機持久化重試,無 destinations/DLQ 或正式 autoscaling |
240
246
  | GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
241
247
  | S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
242
- | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
248
+ | DynamoDB / SQS / SSM | 明列資料操作、Binary、FIFO group/dedup/batch、SSM 版本/path/history;無交易/TTL/Streams、SQS 背景 poller/DLQ/加密、KMS/SecureString |
249
+ | CloudWatch Logs | group/stream、put/get/filter events、retention 設定、CLI/UI/boto3;無 stdout 自動收集、Insights、subscriptions 或 retention 刪除保證 |
243
250
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
244
251
  | API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
245
252
  | Azure | account 與資源群組目錄;尚無 Azure Functions 或其他 Azure runtime |
@@ -4,6 +4,11 @@
4
4
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
5
5
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
6
6
 
7
+ 0.9.6 新增 AWS Lambda Event/DryRun、configuration 更新、JMESPath `--query`、
8
+ json/text/yaml 輸出、SQS FIFO/batch、SSM path/history、DynamoDB Binary
9
+ 及 CloudWatch Logs。需要配套後端;Event 202 只表示持久化接受,並非 handler
10
+ 執行完成。仍無完整 AWS CLI 自動分頁、table、Layers/Image/aliases 或全量 IAM。
11
+
7
12
  0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
8
13
  支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
9
14
  insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
@@ -43,7 +48,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
43
48
  ## Cloudflare 前端部署與 R2
44
49
 
45
50
  ```bash
46
- pip install 'fufu-cloud-cli==0.9.5'
51
+ pip install 'fufu-cloud-cli==0.9.6'
47
52
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
48
53
  wrangler-fufu capabilities
49
54
  wrangler-fufu pages project create frontend --production-branch main
@@ -214,10 +219,11 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
214
219
  | --- | --- |
215
220
  | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
216
221
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
217
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
222
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、configuration、RequestResponse/Event/DryRun、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 6 MiB/Event 1 MiB;Event 為單機持久化重試,無 destinations/DLQ 或正式 autoscaling |
218
223
  | GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
219
224
  | S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
220
- | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
225
+ | DynamoDB / SQS / SSM | 明列資料操作、Binary、FIFO group/dedup/batch、SSM 版本/path/history;無交易/TTL/Streams、SQS 背景 poller/DLQ/加密、KMS/SecureString |
226
+ | CloudWatch Logs | group/stream、put/get/filter events、retention 設定、CLI/UI/boto3;無 stdout 自動收集、Insights、subscriptions 或 retention 刪除保證 |
221
227
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
222
228
  | API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
223
229
  | Azure | account 與資源群組目錄;尚無 Azure Functions 或其他 Azure runtime |
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fufu-cloud-cli"
7
- version = "0.9.5"
7
+ version = "0.9.6"
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"
11
- dependencies = ["PyYAML==6.0.3", "tomli==2.2.1; python_version<'3.11'"]
11
+ dependencies = ["PyYAML==6.0.3", "jmespath==1.1.0", "tomli==2.2.1; python_version<'3.11'"]
12
12
  license = "LicenseRef-Proprietary"
13
13
  license-files = ["LICENSE"]
14
14
 
@@ -30,4 +30,4 @@ bq-fufu = "fufu_cloud_cli.cli:bq_main"
30
30
  where = ["src"]
31
31
 
32
32
  [tool.setuptools.package-data]
33
- fufu_cloud_cli = ["capabilities.json", "pulumi-endpoints.json", "runtime/*/Dockerfile", "runtime/ingress/proxy.py"]
33
+ fufu_cloud_cli = ["capabilities.json", "pulumi-endpoints.json", "runtime/*/Dockerfile", "runtime/ingress/proxy.py", "vendor/AWS-CLI-LICENSE.txt"]
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.9.5"
3
+ __version__ = "0.9.6"
@@ -184,6 +184,14 @@ class ApiClient:
184
184
  return json.loads(raw)
185
185
  except HTTPError as exc:
186
186
  status = exc.code
187
+ if self.provider == 'aws' and '/control/native' in path:
188
+ from .aws_errors import AWS_ERRORS
189
+ try:
190
+ value = json.loads(exc.read(4096))
191
+ code = value.get('code') if isinstance(value, dict) else None
192
+ if code in AWS_ERRORS: raise FufuError(code, 'AWS 操作遭拒;未輸出原始錯誤內容。') from None
193
+ except (ValueError, OSError): pass
194
+ finally: exc.close()
187
195
  exc.close()
188
196
  codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 410: "UPLOAD_EXPIRED", 412: "PRECONDITION_FAILED", 413: "PAYLOAD_LIMIT", 429: "RATE_LIMITED", 507: "STORAGE_CAPACITY"}
189
197
  if status == 507: raise FufuError('STORAGE_CAPACITY', 'FUFU 主機磁碟容量不足;這是目前可用空間的限制,並非雲端物件大小上限。') from None
@@ -0,0 +1,6 @@
1
+ """Only documented provider error codes may cross the public transport boundary."""
2
+ AWS_ERRORS = frozenset('''ConditionalCheckFailedException ResourceNotFoundException ResourceInUseException ValidationException
3
+ NoSuchKey NoSuchBucket NotFound NotModified ParameterNotFound ParameterAlreadyExists ParameterVersionNotFound QueueDoesNotExist
4
+ PreconditionFailed InvalidRange InvalidParameterException InvalidParameterValue InvalidAttributeName InvalidAttributeValue QueueNameExists
5
+ PurgeQueueInProgress ReceiptHandleIsInvalid BatchEntryIdsNotDistinct EmptyBatchRequest BatchRequestTooLong InvalidBatchEntryId
6
+ TooManyEntriesInBatchRequest InvalidMessageContents AWS.SimpleQueueService.NonExistentQueue TooManyRequestsException InvalidRequestContentException'''.split())
@@ -0,0 +1,65 @@
1
+ """AWS JMESPath and native text formatting over the supported response subset."""
2
+ import io
3
+ import json
4
+
5
+ import jmespath
6
+ from jmespath.exceptions import JMESPathError
7
+
8
+ from .errors import FufuError, require
9
+ from .vendor.aws_text import format_text
10
+
11
+
12
+ def expression(value):
13
+ require(isinstance(value, str) and 0 < len(value) <= 65536,
14
+ 'INVALID_QUERY', 'AWS query 必須為非空 JMESPath 運算式,最多 65536 字元。')
15
+ try:
16
+ return jmespath.compile(value)
17
+ except JMESPathError:
18
+ raise FufuError('INVALID_QUERY', '無效的 AWS JMESPath query;未執行操作。', 2) from None
19
+
20
+
21
+ def configuration(value):
22
+ if 'FunctionName' in value:
23
+ return value
24
+ resources = value.get('resources', {})
25
+ fields = {'FunctionName': value.get('name'), 'FunctionArn': value.get('functionArn'),
26
+ 'Runtime': value.get('runtime'), 'Handler': value.get('handler'), 'Role': value.get('role'),
27
+ 'MemorySize': resources.get('memoryMiB'), 'Timeout': resources.get('timeoutSeconds'),
28
+ 'RevisionId': value.get('revision'), 'LastModified': value.get('createdAt')}
29
+ result = {key: item for key, item in fields.items() if item is not None}
30
+ if resources.get('tmpMiB') is not None: result['EphemeralStorage'] = {'Size': resources['tmpMiB']}
31
+ if value.get('status') == 'running': result.update(State='Active', LastUpdateStatus='Successful')
32
+ if 'env' in value: result['Environment'] = {'Variables': value['env']}
33
+ result['Fufu'] = {key: value[key] for key in ['mode', 'backend', 'readiness', 'serviceId', 'publicAccess', 'iamEnforced'] if key in value}
34
+ result['Fufu'].setdefault('mode', 'simplified')
35
+ return result
36
+
37
+
38
+ def native(value, argv):
39
+ if argv[:1] != ['lambda'] or len(argv) < 2 or not isinstance(value, dict):
40
+ return value
41
+ action = argv[1]
42
+ if action == 'list-functions':
43
+ return {**value, 'Functions': [configuration(item) for item in value.get('Functions', [])]}
44
+ if action == 'get-function':
45
+ return {'Configuration': configuration(value), 'Fufu': {'mode': 'simplified', 'codeDownloadAvailable': False}}
46
+ if action in {'create-function', 'update-function-code', 'get-function-configuration', 'update-function-configuration'}:
47
+ return configuration(value)
48
+ if action == 'delete-function': return None
49
+ return value
50
+
51
+
52
+ def render(value, output='json', query=None):
53
+ require(output in {'json', 'text', 'yaml', 'off'}, 'UNSUPPORTED_FORMAT',
54
+ 'AWS 輸出支援 json、text、yaml、off;table 與 yaml-stream 尚未支援。')
55
+ try:
56
+ value = query.search(value) if query else value
57
+ except JMESPathError:
58
+ raise FufuError('INVALID_QUERY', 'AWS query 無法套用到回應;操作可能已完成,請先查詢資源狀態。', 2) from None
59
+ if output == 'off': return ''
60
+ if output == 'text':
61
+ stream = io.StringIO(); format_text(value, stream); return stream.getvalue()
62
+ if output == 'yaml':
63
+ import yaml
64
+ return yaml.safe_dump(value, allow_unicode=True, sort_keys=False)
65
+ return json.dumps(value, ensure_ascii=False, indent=4) + '\n'
@@ -103,6 +103,10 @@
103
103
  "operations": [
104
104
  "create-function-python310-python312-zip",
105
105
  "invoke-RequestResponse",
106
+ "invoke-Event-api-only",
107
+ "invoke-DryRun-api-only",
108
+ "get-function-configuration",
109
+ "update-function-configuration",
106
110
  "get-function",
107
111
  "list-functions",
108
112
  "delete-function",
@@ -113,7 +117,7 @@
113
117
  "AWS-IAM-inline-policy-subset",
114
118
  "boto3-data-transport-subset",
115
119
  "no-managed-timeout-equivalence",
116
- "no-async-Invoke",
120
+ "async-API-only-single-host-queue-no-destinations-or-DLQ",
117
121
  "remote-ZIP-50MiB-expanded-250MiB",
118
122
  "synchronous-payload-6MiB",
119
123
  "workload-lease-24h-renewable-while-active",
@@ -222,9 +226,12 @@
222
226
  "no-Entra-STS"
223
227
  ]
224
228
  },
229
+ "aws-cloudwatch-logs": {"status": "implemented", "backend": "api", "mode": "simplified", "operations": ["log-group-crud", "log-stream-crud", "put-get-filter-events", "retention-policy", "CLI-UI-boto3"], "limitations": ["no-automatic-stdout-collection", "simple-text-filters-only", "no-Insights-subscriptions-or-KMS", "no-guaranteed-retention-deletion", "service-page-tokens-no-CLI-auto-pagination"]},
230
+ "aws-lambda-async": {"status": "implemented", "backend": "api", "mode": "simplified", "operations": ["invoke-Event", "invoke-DryRun", "CLI-UI-boto3-Invoke"], "limitations": ["1MiB-payload", "single-host-SQLite-two-dispatchers", "at-least-once", "two-handler-retries-60s-120s", "system-retries-up-to-6h", "no-destinations-or-DLQ", "no-AWS-scaling-or-managed-timeout-equivalence", "10000-events-or-512MiB-encoded-queue-per-tenant"]},
231
+ "aws-cli-output": {"status": "implemented", "mode": "simplified", "operations": ["JMESPath-query", "json", "text", "yaml"], "limitations": ["no-table-or-yaml-stream", "no-full-CLI-auto-pagination", "Lambda-response-subset-no-Code-download"]},
225
232
  "aws-data-services": {
226
233
  "status": "partial",
227
- "reason": "DynamoDB、S3、SQS、SSM 已接 CLI 與 boto3;事件僅 SQS 手動 drain,其他服务不推定可用。"
234
+ "reason": "DynamoDB、S3、SQS、SSM、CloudWatch Logs 與 Lambda Invoke 已接 CLI/boto3 明列子集;Lambda Event 可背景執行,SQS mapping 仍需手動 drain。"
228
235
  },
229
236
  "gcp-data-services": {
230
237
  "status": "partial",
@@ -281,6 +288,10 @@
281
288
  "list-queues",
282
289
  "delete-queue",
283
290
  "send-message",
291
+ "send-message-batch",
292
+ "delete-message-batch",
293
+ "change-message-visibility-batch",
294
+ "purge-queue",
284
295
  "receive-message",
285
296
  "delete-message",
286
297
  "get-queue-attributes",
@@ -291,13 +302,13 @@
291
302
  "terraform-lifecycle"
292
303
  ],
293
304
  "limitations": [
294
- "standard-queues",
305
+ "Standard-and-FIFO-queues",
295
306
  "message-1MiB",
296
307
  "long-poll-up-to-20s",
297
308
  "receive-max-10",
298
309
  "fufu-QueueUrl",
299
- "no-FIFO",
300
- "no-KMS-or-SQS-encryption",
310
+ "no-DLQ-or-standard-fair-queues",
311
+ "SSE-default-false-unlike-AWS-true-no-KMS",
301
312
  "manual-Lambda-drain-only",
302
313
  "no-background-poller"
303
314
  ]
@@ -309,6 +320,10 @@
309
320
  "put-parameter",
310
321
  "get-parameter",
311
322
  "get-parameters",
323
+ "get-parameters-by-path",
324
+ "get-parameter-history",
325
+ "get-parameter-version-selector",
326
+ "delete-parameters",
312
327
  "delete-parameter",
313
328
  "describe-parameters-exact-name",
314
329
  "list-tags-for-resource",
@@ -748,7 +763,8 @@
748
763
  "mode": "simplified",
749
764
  "limitations": [
750
765
  "botocore-event-transport",
751
- "DynamoDB-S3-SQS-SSM-only",
766
+ "DynamoDB-S3-SQS-SSM-SecretsManager-Logs-Lambda-Invoke-subsets",
767
+ "modeled-binary-timestamps-and-service-exceptions",
752
768
  "FUFU-token-not-SigV4",
753
769
  "no-real-cloud-fallback",
754
770
  "no-presigned-URLs"
@@ -326,7 +326,7 @@ def gcloud_command(argv):
326
326
  return 0 if status < 400 and not failure else 1
327
327
 
328
328
 
329
- def aws_command(argv):
329
+ def aws_command(argv, emit_result=emit):
330
330
  p = Parser(prog="aws-fufu", description="Lambda ZIP 與同步 invoke、IAM workload role 子集。")
331
331
  root = p.add_subparsers(dest="service", required=True)
332
332
  s = root.add_parser("lambda").add_subparsers(dest="action", required=True)
@@ -336,26 +336,52 @@ def aws_command(argv):
336
336
  c.add_argument("--handler", required=True)
337
337
  c.add_argument("--zip-file", required=True)
338
338
  c.add_argument('--role')
339
+ c.add_argument('--environment', help='原生 Environment JSON 或 file:// JSON')
339
340
  deployment_options(c)
340
341
  update = s.add_parser('update-function-code')
341
342
  update.add_argument('--function-name', required=True)
342
343
  update.add_argument('--zip-file', required=True)
343
344
  deployment_options(update)
344
- for action in ("get-function", "delete-function", "invoke"):
345
+ configure = s.add_parser('update-function-configuration')
346
+ configure.add_argument('--function-name', required=True)
347
+ configure.add_argument('--environment', help='原生 Environment JSON 或 file:// JSON;Variables 為完整替換')
348
+ configure.add_argument('--role')
349
+ deployment_options(configure)
350
+ for action in ("get-function", "get-function-configuration", "delete-function", "invoke"):
345
351
  q = s.add_parser(action)
346
352
  q.add_argument("--function-name", required=True)
347
353
  if action == "invoke":
348
354
  q.add_argument("--payload", default="{}")
349
- q.add_argument("--invocation-type", choices=["RequestResponse"], default="RequestResponse")
355
+ q.add_argument("--invocation-type", choices=["RequestResponse", "Event", "DryRun"], default="RequestResponse")
350
356
  q.add_argument("--cli-binary-format", choices=["raw-in-base64-out"], default="raw-in-base64-out")
351
357
  q.add_argument("--request-timeout", type=int, help='呼叫等待期限;預設使用服務配置')
352
358
  q.add_argument("outfile")
353
359
  s.add_parser("list-functions")
354
360
  a = p.parse_args(argv)
355
361
  kind = "aws-lambda-sync"
362
+ def environment():
363
+ original = env_from_names(a.env)
364
+ if getattr(a, 'environment', None) is not None:
365
+ from .control import read_json
366
+ value = read_json(a.environment)
367
+ require(isinstance(value, dict) and set(value) == {'Variables'} and not original,
368
+ 'INVALID_INPUT', 'Environment 必須只包含 Variables,且不能與 --env 混用。')
369
+ return manifest.environment(value['Variables'])
370
+ return original
371
+ if a.action == 'get-function-configuration':
372
+ return compute_backend(kind).configuration(kind, a.function_name)
373
+ if a.action == 'update-function-configuration':
374
+ require(api_selected(), 'API_BACKEND_REQUIRED', 'Lambda 設定更新需要配套的 FUFU API/worker。')
375
+ body = {}
376
+ if a.environment is not None or a.env: body['environment_patch'] = {'replace': True, 'values': environment(), 'remove': []}
377
+ resources = resource_options(a, kind)
378
+ if resources: body['resources'] = resources
379
+ if a.role is not None: body['role'] = a.role
380
+ require(bool(body), 'INVALID_INPUT', '請指定要更新的 Environment、資源限制或 Role。')
381
+ return compute_backend(kind).configure(kind, a.function_name, body)
356
382
  if a.action in {"create-function", "update-function-code"}:
357
383
  manifest.name(a.function_name)
358
- env = env_from_names(a.env)
384
+ env = environment()
359
385
  require(not getattr(a, 'role', None) or api_selected(), 'IAM_BACKEND_REQUIRED', 'Lambda IAM role 需要 API 模式。')
360
386
  require(a.zip_file.startswith("fileb://"), "INVALID_ZIP", "ZIP 參數請使用 fileb:// 路徑。")
361
387
  b = compute_backend(kind)
@@ -384,6 +410,7 @@ def aws_command(argv):
384
410
  if a.action == "delete-function":
385
411
  return compute_backend(kind).delete(kind, a.function_name)
386
412
  data = payload(a.payload)
413
+ require(a.invocation_type == 'RequestResponse' or api_selected(), 'API_BACKEND_REQUIRED', '非同步/DryRun 呼叫需要 FUFU API 與持久化 worker。')
387
414
  destination = Path(a.outfile)
388
415
  require(not destination.exists() and not destination.is_symlink() and destination.parent.is_dir(),
389
416
  "OUTPUT_EXISTS", "輸出檔必須是新檔案且父目錄已存在,避免覆寫既有資料。")
@@ -391,12 +418,14 @@ def aws_command(argv):
391
418
  fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
392
419
  try:
393
420
  with os.fdopen(fd, "wb") as out:
394
- status, body, failure = compute_backend(kind).invoke(kind, a.function_name, data, timeout=a.request_timeout)
421
+ options = {'timeout': a.request_timeout}
422
+ if api_selected(): options['invocation_type'] = a.invocation_type
423
+ status, body, failure = compute_backend(kind).invoke(kind, a.function_name, data, **options)
395
424
  out.write(body)
396
425
  result = {"StatusCode": status}
397
426
  if failure:
398
427
  result["FunctionError"] = failure
399
- emit(result)
428
+ emit_result(result)
400
429
  # AWS 同步 invoke 的 handler error 透過 FunctionError 與 payload 回報。
401
430
  return 0 if status < 400 else 1
402
431
  except Exception:
@@ -408,8 +437,13 @@ def entry(provider, argv=None):
408
437
  selected_keys = ['FUFU_SELECTED_REGION', 'FUFU_SELECTED_PROJECT', 'FUFU_ASSUME_ROLE']
409
438
  original_environment = {key: os.environ.get(key) for key in selected_keys}
410
439
  output_format = None; output_filter = None; output_sort = None; output_limit = None
440
+ aws_query = None; aws_format = 'json'
411
441
  def emit(value):
412
- if provider == 'gcloud-fufu' and (output_format or output_filter or output_sort or output_limit is not None):
442
+ if provider == 'aws-fufu':
443
+ from .aws_output import native, render
444
+ shaped = native(value, argv)
445
+ if shaped is not None or aws_query is not None: sys.stdout.write(render(shaped, aws_format, aws_query))
446
+ elif provider == 'gcloud-fufu' and (output_format or output_filter or output_sort or output_limit is not None):
413
447
  from .gcloud_output import native, render, select
414
448
  shaped = native(value, argv)
415
449
  if output_filter or output_sort or output_limit is not None:
@@ -422,6 +456,15 @@ def entry(provider, argv=None):
422
456
  require(provider in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu", "wrangler-fufu", "bq-fufu"}, "UNSUPPORTED_CLI", "未知 CLI 入口。")
423
457
  if argv == ['--version']:
424
458
  sys.stdout.write(('fufu-cloud-cli ' if provider == 'gcloud-fufu' else '') + __version__ + '\n'); return
459
+ if provider == 'aws-fufu' and argv in [['--help'], ['-h']]:
460
+ p = Parser(prog='aws-fufu', description='AWS 部署及資料 API 的明列子集;租戶 IAM、官方 JMESPath 與 text formatter。')
461
+ p.add_argument('--query', help='JMESPath 回應查詢')
462
+ p.add_argument('--output', choices=['json', 'text', 'yaml', 'off'])
463
+ p.add_argument('--region', help='必須符合 FUFU credential 的區域')
464
+ p.add_argument('--no-cli-pager', action='store_true')
465
+ groups = p.add_subparsers(title='服務')
466
+ for group in ['lambda', 'iam', 'sts', 's3', 's3api', 'dynamodb', 'sqs', 'ssm', 'logs', 'secretsmanager', 'apigatewayv2', 'serverless']: groups.add_parser(group)
467
+ p.print_help(); return
425
468
  if provider == 'gcloud-fufu' and argv in [['--help'], ['-h']]:
426
469
  p = Parser(prog='gcloud-fufu', description='GCP 開發環境的可執行子集;使用 FUFU credential,功能範圍請見 fufu capabilities。')
427
470
  for flag, description in [('--format', 'json/yaml/value/table/csv;支援 dotted path 投影'),
@@ -458,11 +501,18 @@ def entry(provider, argv=None):
458
501
  rest = []; i = 0
459
502
  while i < len(argv):
460
503
  flag, _, inline = argv[i].partition('=')
461
- if flag in {'--region', '--project', '--output', '--format', '--impersonate-service-account', '--filter', '--sort-by'} or flag == '--limit' and provider == 'gcloud-fufu' and 'list' in argv:
504
+ if flag in {'--region', '--project', '--output', '--format', '--impersonate-service-account', '--filter', '--sort-by', '--query'} or flag == '--limit' and provider == 'gcloud-fufu' and 'list' in argv:
462
505
  value = inline if '=' in argv[i] else (argv[i + 1] if i + 1 < len(argv) else '')
463
506
  i += 1 if '=' in argv[i] else 2
464
507
  require(bool(value) and not value.startswith('--'), 'UNSUPPORTED_ARGUMENT', '全域旗標缺少值。')
465
- if flag == '--impersonate-service-account':
508
+ if flag == '--query':
509
+ require(provider == 'aws-fufu', 'UNSUPPORTED_ARGUMENT', '--query 只適用於 aws-fufu。')
510
+ from .aws_output import expression
511
+ aws_query = expression(value)
512
+ elif flag == '--output' and provider == 'aws-fufu':
513
+ require(value in {'json', 'text', 'yaml', 'off'}, 'UNSUPPORTED_FORMAT', 'AWS 輸出支援 json、text、yaml、off。')
514
+ aws_format = value
515
+ elif flag == '--impersonate-service-account':
466
516
  require(provider == 'gcloud-fufu', 'UNSUPPORTED_ARGUMENT', '此旗標只適用於 gcloud-fufu。')
467
517
  os.environ['FUFU_ASSUME_ROLE'] = value
468
518
  elif flag == '--format' and provider == 'gcloud-fufu':
@@ -520,7 +570,7 @@ def entry(provider, argv=None):
520
570
  if provider == 'fufu' and argv and argv[0] in {'gateway', 'events', 'mock'}:
521
571
  from .control import fufu_control
522
572
  emit(fufu_control(argv, Parser)); return
523
- if provider == 'aws-fufu' and argv and argv[0] in {'iam', 'dynamodb', 's3api', 'sqs', 'ssm'}:
573
+ if provider == 'aws-fufu' and argv and argv[0] in {'iam', 'dynamodb', 's3api', 'sqs', 'ssm', 'logs'}:
524
574
  from .control import command
525
575
  emit(command(argv, Parser)); return
526
576
  if provider == 'gcloud-fufu' and argv and argv[0] in {'iam', 'projects'}:
@@ -547,7 +597,7 @@ def entry(provider, argv=None):
547
597
  if provider in remote and not compute and (provider == "az-fufu" or api_selected() or (argv and argv[0] in api_services[provider])):
548
598
  result = remote_command(remote[provider], argv, Parser)
549
599
  else:
550
- result = {"fufu": fufu_command, "aws-fufu": aws_command, "gcloud-fufu": gcloud_command}[provider](argv)
600
+ result = aws_command(argv, emit_result=emit) if provider == 'aws-fufu' else {"fufu": fufu_command, "gcloud-fufu": gcloud_command}[provider](argv)
551
601
  if type(result) is int:
552
602
  raise SystemExit(result)
553
603
  if provider == 'az-fufu' and argv and argv[0] != 'account' and isinstance(result, dict):
@@ -28,24 +28,35 @@ AWS_OPERATIONS = {
28
28
  },
29
29
  's3api': {
30
30
  'CreateBucket': 'Bucket CreateBucketConfiguration', 'DeleteBucket': 'Bucket', 'ListBuckets': '', 'HeadBucket': 'Bucket',
31
- 'ListObjectsV2': 'Bucket Prefix MaxKeys ContinuationToken Delimiter StartAfter', 'PutObject': 'Bucket Key Body ContentType Metadata',
32
- 'GetObject': 'Bucket Key ChecksumMode', 'HeadObject': 'Bucket Key ChecksumMode', 'DeleteObject': 'Bucket Key',
31
+ 'ListObjectsV2': 'Bucket Prefix MaxKeys ContinuationToken Delimiter StartAfter EncodingType', 'PutObject': 'Bucket Key Body ContentType Metadata',
32
+ 'GetObject': 'Bucket Key ChecksumMode Range IfMatch IfNoneMatch', 'HeadObject': 'Bucket Key ChecksumMode IfMatch IfNoneMatch', 'DeleteObject': 'Bucket Key',
33
33
  },
34
34
  'sqs': {
35
35
  'CreateQueue': 'QueueName Attributes tags', 'GetQueueUrl': 'QueueName', 'ListQueues': 'QueueNamePrefix MaxResults NextToken', 'DeleteQueue': 'QueueUrl',
36
36
  'ListQueueTags': 'QueueUrl', 'TagQueue': 'QueueUrl Tags', 'UntagQueue': 'QueueUrl TagKeys',
37
37
  'GetQueueAttributes': 'QueueUrl AttributeNames', 'SetQueueAttributes': 'QueueUrl Attributes',
38
- 'SendMessage': 'QueueUrl MessageBody DelaySeconds MessageAttributes', 'ReceiveMessage': 'QueueUrl MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds MessageAttributeNames AttributeNames',
38
+ 'SendMessage': 'QueueUrl MessageBody DelaySeconds MessageAttributes MessageGroupId MessageDeduplicationId', 'ReceiveMessage': 'QueueUrl MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds MessageAttributeNames AttributeNames MessageSystemAttributeNames ReceiveRequestAttemptId',
39
+ 'SendMessageBatch': 'QueueUrl Entries', 'DeleteMessageBatch': 'QueueUrl Entries', 'ChangeMessageVisibilityBatch': 'QueueUrl Entries', 'PurgeQueue': 'QueueUrl',
39
40
  'DeleteMessage': 'QueueUrl ReceiptHandle', 'ChangeMessageVisibility': 'QueueUrl ReceiptHandle VisibilityTimeout',
40
41
  },
41
- 'ssm': { 'PutParameter': 'Name Value Type Overwrite Description Tags', 'GetParameter': 'Name WithDecryption', 'GetParameters': 'Names WithDecryption', 'DeleteParameter': 'Name',
42
+ 'ssm': { 'PutParameter': 'Name Value Type Overwrite Description Tags Tier DataType', 'GetParameter': 'Name WithDecryption', 'GetParameters': 'Names WithDecryption', 'DeleteParameter': 'Name',
42
43
  'DescribeParameters': 'ParameterFilters', 'ListTagsForResource': 'ResourceType ResourceId',
43
- 'AddTagsToResource': 'ResourceType ResourceId Tags', 'RemoveTagsFromResource': 'ResourceType ResourceId TagKeys' },
44
+ 'AddTagsToResource': 'ResourceType ResourceId Tags', 'RemoveTagsFromResource': 'ResourceType ResourceId TagKeys',
45
+ 'GetParametersByPath': 'Path Recursive WithDecryption MaxResults NextToken', 'GetParameterHistory': 'Name WithDecryption MaxResults NextToken', 'DeleteParameters': 'Names' },
46
+ 'logs': {
47
+ 'CreateLogGroup': 'logGroupName', 'DeleteLogGroup': 'logGroupName', 'DescribeLogGroups': 'logGroupNamePrefix limit nextToken',
48
+ 'CreateLogStream': 'logGroupName logStreamName', 'DeleteLogStream': 'logGroupName logStreamName',
49
+ 'DescribeLogStreams': 'logGroupName logStreamNamePrefix orderBy descending limit nextToken',
50
+ 'PutLogEvents': 'logGroupName logStreamName logEvents sequenceToken',
51
+ 'GetLogEvents': 'logGroupName logStreamName startTime endTime startFromHead limit nextToken',
52
+ 'FilterLogEvents': 'logGroupName logStreamNames logStreamNamePrefix startTime endTime filterPattern limit nextToken',
53
+ 'PutRetentionPolicy': 'logGroupName retentionInDays', 'DeleteRetentionPolicy': 'logGroupName',
54
+ },
44
55
  }
45
- JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes Tags tags ParameterFilters'.split())
46
- LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames TagKeys'.split())
47
- BOOL_FIELDS = set('ConsistentRead ScanIndexForward WithDecryption Overwrite Enabled'.split())
48
- INT_FIELDS = set('Limit MaxKeys MaxResults MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds DelaySeconds BatchSize'.split())
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
+ LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames MessageSystemAttributeNames TagKeys logStreamNames'.split())
58
+ BOOL_FIELDS = set('ConsistentRead ScanIndexForward WithDecryption Overwrite Enabled Recursive descending startFromHead'.split())
59
+ INT_FIELDS = set('Limit MaxKeys MaxResults MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds DelaySeconds BatchSize limit startTime endTime retentionInDays'.split())
49
60
 
50
61
 
51
62
  def dashed(value):
@@ -60,6 +71,19 @@ def read_json(value):
60
71
  return json.loads(value)
61
72
 
62
73
 
74
+ def flat_map(value):
75
+ if value.startswith(('{', '[', 'file://')): return read_json(value)
76
+ import shlex
77
+ lexer = shlex.shlex(value, posix=True); lexer.whitespace = ','; lexer.whitespace_split = True; lexer.commenters = ''
78
+ result = {}
79
+ for item in lexer:
80
+ key, separator, val = item.partition('='); key = key.strip()
81
+ require(bool(separator) and bool(key) and key not in result, 'INVALID_INPUT', 'AWS map shorthand 必須使用不同的 key=value。')
82
+ result[key] = val
83
+ require(bool(result), 'INVALID_INPUT', 'AWS map 不可為空 shorthand;空物件請使用 {}。')
84
+ return result
85
+
86
+
63
87
  def command(argv, Parser):
64
88
  p = Parser(prog='aws-fufu', description='FUFU IAM 與資料服務子集;原生欄位、預設拒絕與 explicit Deny。')
65
89
  services = p.add_subparsers(dest='service', required=True)
@@ -86,9 +110,10 @@ def command(argv, Parser):
86
110
  for key in AWS_OPERATIONS[a.service][a.native_operation].split():
87
111
  value = getattr(a, key)
88
112
  if value is None: continue
89
- require(key not in params, 'INVALID_INPUT', '同一欄位不能同時由 JSON 與旗標指定。')
113
+ # Official CLI command-line values override the same cli-input-json field.
90
114
  if key in {'AttributeDefinitions', 'KeySchema'}:
91
115
  value = read_json(value[0]) if len(value) == 1 and (value[0].startswith(('[', 'file://'))) else [dict(item.split('=', 1) for item in v.split(',')) for v in value]
116
+ elif key in {'Attributes', 'Metadata', 'ExpressionAttributeNames', 'tags'} or key == 'Tags' and a.service == 'sqs': value = flat_map(value)
92
117
  elif key in JSON_FIELDS and (key != 'Key' or a.service == 'dynamodb'): value = read_json(value)
93
118
  if key == 'Body':
94
119
  file = Path(value.removeprefix('fileb://').removeprefix('file://'))
@@ -108,12 +133,12 @@ def command(argv, Parser):
108
133
  params.setdefault('Overwrite', False)
109
134
  params.setdefault('Type', 'String')
110
135
  if a.service == 'sqs' and 'QueueUrl' in params:
111
- require(isinstance(params['QueueUrl'], str) and re.fullmatch(r'(?:fufu://sqs/|https://sqs\.[a-z0-9-]+\.fufu\.invalid/[0-9]{12}/)[A-Za-z0-9_-]{1,80}', params['QueueUrl']), 'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU 租戶資源識別。')
136
+ require(isinstance(params['QueueUrl'], str) and re.fullmatch(r'(?:fufu://sqs/|https://sqs\.[a-z0-9-]+\.fufu\.invalid/[0-9]{12}/)[A-Za-z0-9_-]+(?:\.fifo)?', params['QueueUrl']) and len(params['QueueUrl'].rsplit('/', 1)[-1]) <= 80, 'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU 租戶資源識別。')
112
137
  with ApiClient('aws', read_only=bool(re.match(r'^(Get|List|Describe|Query|Scan|BatchGet|Simulate|Head)', a.native_operation))) as api:
113
138
  if a.service == 's3api' and a.native_operation == 'PutObject' and isinstance(params.get('Body'), Path):
114
139
  from .object_transfer import upload_file
115
140
  return upload_file(api, params['Body'], params.get('Bucket'), params.get('Key'), content_type=params.get('ContentType'), metadata=params.get('Metadata'))
116
- if a.service == 's3api' and a.native_operation == 'GetObject':
141
+ if a.service == 's3api' and a.native_operation == 'GetObject' and not (set(params) - {'Bucket', 'Key'}):
117
142
  from .object_transfer import download_file
118
143
  return download_file(api, a.outfile, params.get('Bucket'), params.get('Key'))
119
144
  result = api.request('POST', '/control/native', {'service': 's3' if a.service == 's3api' else a.service, 'operation': a.native_operation, 'params': params})
@@ -143,6 +143,10 @@ class RemoteBackend:
143
143
  with ApiClient(self.provider, read_only=True) as api:
144
144
  return api.request('GET', '/compute/services/' + segment(kind) + '/' + segment(service))
145
145
 
146
+ def configuration(self, kind, service):
147
+ with ApiClient(self.provider, read_only=True) as api:
148
+ return api.request('GET', '/compute/services/' + segment(kind) + '/' + segment(service) + '/configuration')
149
+
146
150
  def configure(self, kind, service, body):
147
151
  with ApiClient(self.provider) as api:
148
152
  return self.wait(api, api.request('PATCH', '/compute/services/' + segment(kind) + '/' + segment(service) + '/configuration', body))
@@ -155,10 +159,10 @@ class RemoteBackend:
155
159
  with ApiClient(self.provider) as api:
156
160
  return self.wait(api, api.request('DELETE', '/compute/services/' + segment(kind) + '/' + segment(service)), timeout=120)
157
161
 
158
- def invoke(self, kind, service, payload, *, path='/', timeout=None):
162
+ def invoke(self, kind, service, payload, *, path='/', timeout=None, invocation_type='RequestResponse'):
159
163
  if timeout is None: timeout = compute_limits(kind)['timeoutSeconds']
160
164
  require(1 <= timeout <= compute_limits(kind)['timeoutSeconds'], 'INVALID_TIMEOUT', '遠端呼叫超過此服務的期限上限。')
161
165
  with ApiClient(self.provider) as api:
162
166
  response = api.request('POST', '/compute/services/' + segment(kind) + '/' + segment(service) + '/invoke',
163
- {'payload': base64.b64encode(payload).decode(), 'path': path, 'timeout': timeout})
167
+ {'payload': base64.b64encode(payload).decode(), 'path': path, 'timeout': timeout, 'invocation_type': invocation_type})
164
168
  return response['status'], base64.b64decode(response['payload'], validate=True), response.get('function_error')
@@ -51,11 +51,16 @@ def rpc(service, operation, params):
51
51
  code = body.get('code') if isinstance(body, dict) else None
52
52
  # Only documented error names are allowed to cross the SDK boundary.
53
53
  allowed = {'ConditionalCheckFailedException', 'ResourceNotFoundException', 'ResourceInUseException',
54
- 'ValidationException', 'NoSuchKey', 'NoSuchBucket', 'ParameterNotFound',
55
- 'ParameterAlreadyExists', 'QueueDoesNotExist'}
54
+ 'ValidationException', 'NoSuchKey', 'NoSuchBucket', 'NotFound', 'NotModified', 'ParameterNotFound',
55
+ 'ParameterAlreadyExists', 'ParameterVersionNotFound', 'QueueDoesNotExist', 'PreconditionFailed',
56
+ 'InvalidRange', 'InvalidParameterException', 'InvalidParameterValue', 'InvalidAttributeName',
57
+ 'InvalidAttributeValue', 'QueueNameExists', 'PurgeQueueInProgress', 'ReceiptHandleIsInvalid',
58
+ 'BatchEntryIdsNotDistinct', 'EmptyBatchRequest', 'BatchRequestTooLong', 'InvalidBatchEntryId',
59
+ 'TooManyEntriesInBatchRequest', 'InvalidMessageContents', 'AWS.SimpleQueueService.NonExistentQueue',
60
+ 'TooManyRequestsException', 'InvalidRequestContentException'}
56
61
  if code not in allowed:
57
62
  code = 'AccessDeniedException' if status in {401, 403} else 'NoSuchKey' if service == 's3' and operation == 'GetObject' and status == 404 else 'FufuUnsupportedCapability' if status == 422 else 'FufuServiceError'
58
- if service in {'dynamodb', 's3', 'sqs', 'ssm', 'secretsmanager'}:
63
+ if service in {'dynamodb', 's3', 'sqs', 'ssm', 'secretsmanager', 'logs', 'lambda'}:
59
64
  from botocore.exceptions import ClientError
60
65
  raise ClientError({'Error': {'Code': code, 'Message': 'FUFU SDK request failed; see the documented capability subset'}, 'ResponseMetadata': {'HTTPStatusCode': status}}, operation) from None
61
66
  raise FufuCapabilityError('FUFU runtime request rejected (HTTP %s)' % status) from None
@@ -69,7 +74,7 @@ def _rpc(service, operation, params):
69
74
  token = os.environ.get('FUFU_WORKLOAD_TOKEN')
70
75
  if not token:
71
76
  raise FufuCapabilityError('FUFU workload credential is required')
72
- if service == 's3' and operation in {'GetObject', 'PutObject'}:
77
+ if service == 's3' and operation in {'GetObject', 'PutObject'} and (operation == 'PutObject' or not (set(params) - {'Bucket', 'Key'})):
73
78
  from botocore.response import StreamingBody
74
79
  allowed = {'Bucket', 'Key'} | ({'Body', 'ContentType', 'Metadata', 'ContentLength'} if operation == 'PutObject' else set())
75
80
  if set(params) - allowed:
@@ -93,7 +98,7 @@ def _rpc(service, operation, params):
93
98
  if len(data) > 48 * 1024**2:
94
99
  raise FufuCapabilityError('FUFU SDK payload exceeds limit')
95
100
  req = Request(endpoint, data=data, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token}, method='POST')
96
- with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=90) as response:
101
+ with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=930 if service == 'lambda' and operation == 'Invoke' else 90) as response:
97
102
  body = response.read(48 * 1024**2 + 1)
98
103
  if len(body) > 48 * 1024**2:
99
104
  raise FufuCapabilityError('FUFU SDK response exceeds limit')
@@ -150,6 +155,24 @@ def access_secret_version(name):
150
155
  _installed = False
151
156
 
152
157
 
158
+ def modeled_response(value, shape):
159
+ """Restore SDK model types after the JSON gateway without changing application map keys."""
160
+ if value is None or shape is None: return value
161
+ if shape.type_name == 'blob':
162
+ if isinstance(value, dict) and set(value) == {'__fufu_bytes'}: value = value['__fufu_bytes']
163
+ return base64.b64decode(value, validate=True) if isinstance(value, str) else value
164
+ if shape.type_name == 'timestamp':
165
+ from botocore.utils import parse_to_aware_datetime
166
+ return parse_to_aware_datetime(value)
167
+ if shape.type_name == 'structure' and isinstance(value, dict):
168
+ return {key: modeled_response(item, shape.members.get(key)) for key, item in value.items()}
169
+ if shape.type_name == 'map' and isinstance(value, dict):
170
+ return {key: modeled_response(item, shape.value) for key, item in value.items()}
171
+ if shape.type_name == 'list' and isinstance(value, list):
172
+ return [modeled_response(item, shape.member) for item in value]
173
+ return value
174
+
175
+
153
176
  def install():
154
177
  global _installed
155
178
  if _installed or not os.environ.get('FUFU_RUNTIME_ENDPOINT'):
@@ -160,7 +183,7 @@ def install():
160
183
  original = botocore.session.Session.create_client
161
184
 
162
185
  def create_client(session, service_name, *args, **kwargs):
163
- if service_name not in {'dynamodb', 's3', 'sqs', 'ssm', 'secretsmanager'}:
186
+ if service_name not in {'dynamodb', 's3', 'sqs', 'ssm', 'secretsmanager', 'logs', 'lambda'}:
164
187
  raise FufuCapabilityError('FUFU SDK service is not implemented: ' + service_name)
165
188
  kwargs['aws_access_key_id'] = 'FUFU_WORKLOAD'
166
189
  kwargs['aws_secret_access_key'] = 'fufu-transport-does-not-use-sigv4'
@@ -176,12 +199,21 @@ def install():
176
199
  context['fufu_params'] = params
177
200
 
178
201
  def call(model, context, **unused):
179
- result = rpc(service_name, model.name, context['fufu_params'])
180
- if service_name == 's3' and isinstance(result.get('Body'), dict):
181
- data = base64.b64decode(result['Body']['__fufu_bytes'], validate=True)
182
- result['Body'] = StreamingBody(io.BytesIO(data), len(data))
183
- result['ResponseMetadata'] = {'HTTPStatusCode': 200, 'HTTPHeaders': {'x-fufu-mode': 'simplified'}, 'RetryAttempts': 0}
184
- return AWSResponse('http://fufu-api', 200, {}, None), result
202
+ from botocore.exceptions import ClientError
203
+ try: result = rpc(service_name, model.name, context['fufu_params'])
204
+ except ClientError as error:
205
+ # Native SDK callers commonly catch client.exceptions.<Code>.
206
+ # Preserve the modeled exception, not just its JSON error code.
207
+ exception = client.exceptions.from_code(error.response['Error']['Code'])
208
+ raise exception(error.response, model.name) from None
209
+ result = modeled_response(result, model.output_shape)
210
+ for field in ['Body'] if service_name == 's3' else ['Payload'] if service_name == 'lambda' else []:
211
+ if isinstance(result.get(field), (bytes, bytearray)):
212
+ data = result[field]; result[field] = StreamingBody(io.BytesIO(data), len(data))
213
+ status = 206 if service_name == 's3' and model.name == 'GetObject' and result.get('ContentRange') else 204 if service_name == 's3' and model.name in {'DeleteObject', 'DeleteBucket'} else 200
214
+ if service_name == 'lambda' and model.name == 'Invoke': status = result.get('StatusCode', 200)
215
+ result['ResponseMetadata'] = {'HTTPStatusCode': status, 'HTTPHeaders': {'x-fufu-mode': 'simplified'}, 'RetryAttempts': 0}
216
+ return AWSResponse('http://fufu-api', status, {}, None), result
185
217
 
186
218
  client.meta.events.register('before-parameter-build', capture)
187
219
  client.meta.events.register('before-call', call)
@@ -0,0 +1,12 @@
1
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License"). You
4
+ may not use this file except in compliance with the License. A copy of
5
+ the License is located at
6
+
7
+ http://aws.amazon.com/apache2.0/
8
+
9
+ or in the "license" file accompanying this file. This file is
10
+ distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11
+ ANY KIND, either express or implied. See the License for the specific
12
+ language governing permissions and limitations under the License.
@@ -0,0 +1 @@
1
+ """Small third-party modules with their original licenses."""
@@ -0,0 +1,117 @@
1
+ # Copyright 2012-2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You
4
+ # may not use this file except in compliance with the License. A copy of
5
+ # the License is located at
6
+
7
+ # http://aws.amazon.com/apache2.0/
8
+
9
+ # or in the "license" file accompanying this file. This file is
10
+ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11
+ # ANY KIND, either express or implied. See the License for the specific
12
+ # language governing permissions and limitations under the License.
13
+
14
+
15
+ def format_text(data, stream):
16
+ _format_text(data, stream)
17
+
18
+
19
+ def _format_text(item, stream, identifier=None, scalar_keys=None):
20
+ if isinstance(item, dict):
21
+ _format_dict(scalar_keys, item, identifier, stream)
22
+ elif isinstance(item, list):
23
+ _format_list(item, identifier, stream)
24
+ else:
25
+ # If it's not a list or a dict, we just write the scalar
26
+ # value out directly.
27
+ stream.write(str(item))
28
+ stream.write('\n')
29
+
30
+
31
+ def _format_list(item, identifier, stream):
32
+ if not item:
33
+ return
34
+ if any(isinstance(el, dict) for el in item):
35
+ all_keys = _all_scalar_keys(item)
36
+ for element in item:
37
+ _format_text(
38
+ element,
39
+ stream=stream,
40
+ identifier=identifier,
41
+ scalar_keys=all_keys,
42
+ )
43
+ elif any(isinstance(el, list) for el in item):
44
+ scalar_elements, non_scalars = _partition_list(item)
45
+ if scalar_elements:
46
+ _format_scalar_list(scalar_elements, identifier, stream)
47
+ for non_scalar in non_scalars:
48
+ _format_text(non_scalar, stream=stream, identifier=identifier)
49
+ else:
50
+ _format_scalar_list(item, identifier, stream)
51
+
52
+
53
+ def _partition_list(item):
54
+ scalars = []
55
+ non_scalars = []
56
+ for element in item:
57
+ if isinstance(element, (list, dict)):
58
+ non_scalars.append(element)
59
+ else:
60
+ scalars.append(element)
61
+ return scalars, non_scalars
62
+
63
+
64
+ def _format_scalar_list(elements, identifier, stream):
65
+ if identifier is not None:
66
+ for item in elements:
67
+ stream.write('%s\t%s\n' % (identifier.upper(), item))
68
+ else:
69
+ # For a bare list, just print the contents.
70
+ stream.write('\t'.join([str(item) for item in elements]))
71
+ stream.write('\n')
72
+
73
+
74
+ def _format_dict(scalar_keys, item, identifier, stream):
75
+ scalars, non_scalars = _partition_dict(item, scalar_keys=scalar_keys)
76
+ if scalars:
77
+ if identifier is not None:
78
+ scalars.insert(0, identifier.upper())
79
+ stream.write('\t'.join(scalars))
80
+ stream.write('\n')
81
+ for new_identifier, non_scalar in non_scalars:
82
+ _format_text(item=non_scalar, stream=stream, identifier=new_identifier)
83
+
84
+
85
+ def _all_scalar_keys(list_of_dicts):
86
+ keys_seen = set()
87
+ for item_dict in list_of_dicts:
88
+ for key, value in item_dict.items():
89
+ if not isinstance(value, (dict, list)):
90
+ keys_seen.add(key)
91
+ return list(sorted(keys_seen))
92
+
93
+
94
+ def _partition_dict(item_dict, scalar_keys):
95
+ # Given a dictionary, partition it into two list based on the
96
+ # values associated with the keys.
97
+ # {'foo': 'scalar', 'bar': 'scalar', 'baz': ['not, 'scalar']}
98
+ # scalar = [('foo', 'scalar'), ('bar', 'scalar')]
99
+ # non_scalar = [('baz', ['not', 'scalar'])]
100
+ scalar = []
101
+ non_scalar = []
102
+ if scalar_keys is None:
103
+ # scalar_keys can have more than just the keys in the item_dict,
104
+ # but if user does not provide scalar_keys, we'll grab the keys
105
+ # from the current item_dict
106
+ for key, value in sorted(item_dict.items()):
107
+ if isinstance(value, (dict, list)):
108
+ non_scalar.append((key, value))
109
+ else:
110
+ scalar.append(str(value))
111
+ else:
112
+ for key in scalar_keys:
113
+ scalar.append(str(item_dict.get(key, '')))
114
+ remaining_keys = sorted(set(item_dict.keys()) - set(scalar_keys))
115
+ for remaining_key in remaining_keys:
116
+ non_scalar.append((remaining_key, item_dict[remaining_key]))
117
+ return scalar, non_scalar
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.9.5
3
+ Version: 0.9.6
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
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: PyYAML==6.0.3
10
+ Requires-Dist: jmespath==1.1.0
10
11
  Requires-Dist: tomli==2.2.1; python_version < "3.11"
11
12
  Provides-Extra: yaml
12
13
  Requires-Dist: PyYAML==6.0.3; extra == "yaml"
@@ -26,6 +27,11 @@ Dynamic: license-file
26
27
  `gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
27
28
  只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
28
29
 
30
+ 0.9.6 新增 AWS Lambda Event/DryRun、configuration 更新、JMESPath `--query`、
31
+ json/text/yaml 輸出、SQS FIFO/batch、SSM path/history、DynamoDB Binary
32
+ 及 CloudWatch Logs。需要配套後端;Event 202 只表示持久化接受,並非 handler
33
+ 執行完成。仍無完整 AWS CLI 自動分頁、table、Layers/Image/aliases 或全量 IAM。
34
+
29
35
  0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
30
36
  支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
31
37
  insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
@@ -65,7 +71,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
65
71
  ## Cloudflare 前端部署與 R2
66
72
 
67
73
  ```bash
68
- pip install 'fufu-cloud-cli==0.9.5'
74
+ pip install 'fufu-cloud-cli==0.9.6'
69
75
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
70
76
  wrangler-fufu capabilities
71
77
  wrangler-fufu pages project create frontend --production-branch main
@@ -236,10 +242,11 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
236
242
  | --- | --- |
237
243
  | Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
238
244
  | Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
239
- | AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
245
+ | AWS Lambda | Python 3.10 / 3.12 ZIP、configuration、RequestResponse/Event/DryRun、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 6 MiB/Event 1 MiB;Event 為單機持久化重試,無 destinations/DLQ 或正式 autoscaling |
240
246
  | GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
241
247
  | S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
242
- | DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
248
+ | DynamoDB / SQS / SSM | 明列資料操作、Binary、FIFO group/dedup/batch、SSM 版本/path/history;無交易/TTL/Streams、SQS 背景 poller/DLQ/加密、KMS/SecureString |
249
+ | CloudWatch Logs | group/stream、put/get/filter events、retention 設定、CLI/UI/boto3;無 stdout 自動收集、Insights、subscriptions 或 retention 刪除保證 |
243
250
  | AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
244
251
  | API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
245
252
  | Azure | account 與資源群組目錄;尚無 Azure Functions 或其他 Azure runtime |
@@ -5,6 +5,8 @@ pyproject.toml
5
5
  src/fufu_cloud_cli/__init__.py
6
6
  src/fufu_cloud_cli/__main__.py
7
7
  src/fufu_cloud_cli/api_backend.py
8
+ src/fufu_cloud_cli/aws_errors.py
9
+ src/fufu_cloud_cli/aws_output.py
8
10
  src/fufu_cloud_cli/bigquery.py
9
11
  src/fufu_cloud_cli/capabilities.json
10
12
  src/fufu_cloud_cli/capacity.py
@@ -49,4 +51,7 @@ src/fufu_cloud_cli/terraform_bridge/common.py
49
51
  src/fufu_cloud_cli/terraform_bridge/google.py
50
52
  src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
51
53
  src/fufu_cloud_cli/terraform_bridge/remote_state.py
52
- src/fufu_cloud_cli/terraform_bridge/runner.py
54
+ src/fufu_cloud_cli/terraform_bridge/runner.py
55
+ src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt
56
+ src/fufu_cloud_cli/vendor/__init__.py
57
+ src/fufu_cloud_cli/vendor/aws_text.py
@@ -1,4 +1,5 @@
1
1
  PyYAML==6.0.3
2
+ jmespath==1.1.0
2
3
 
3
4
  [:python_version < "3.11"]
4
5
  tomli==2.2.1
File without changes
File without changes