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.
- {fufu_cloud_cli-0.9.5/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.6}/PKG-INFO +11 -4
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/README.md +9 -3
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/pyproject.toml +3 -3
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/api_backend.py +8 -0
- fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/aws_errors.py +6 -0
- fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/aws_output.py +65 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/capabilities.json +22 -6
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cli.py +61 -11
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/control.py +37 -12
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/remote_compute.py +6 -2
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime_sdk.py +44 -12
- fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/AWS-CLI-LICENSE.txt +12 -0
- fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/__init__.py +1 -0
- fufu_cloud_cli-0.9.6/src/fufu_cloud_cli/vendor/aws_text.py +117 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6/src/fufu_cloud_cli.egg-info}/PKG-INFO +11 -4
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/SOURCES.txt +6 -1
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/requires.txt +1 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/LICENSE +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/setup.cfg +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/bigquery.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/docker_backend.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcloud_output.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcp_infra.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcp_sdk.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/gcs.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pubsub.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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 |
|
|
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.
|
|
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
|
|
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 |
|
|
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.
|
|
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"]
|
|
@@ -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-
|
|
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
|
|
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
|
-
"
|
|
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-
|
|
300
|
-
"
|
|
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-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 == '
|
|
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 == '--
|
|
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 =
|
|
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
|
-
|
|
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_-]
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 |
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/aws_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.5 → fufu_cloud_cli-0.9.6}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|