fufu-cloud-cli 0.5.0__py3-none-any.whl

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.
@@ -0,0 +1,185 @@
1
+ """IAM and data commands with native AWS JSON field names."""
2
+ import base64
3
+ import json
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from .api_backend import ApiClient, segment
8
+ from .errors import FufuError, require
9
+
10
+ AWS_OPERATIONS = {
11
+ 'iam': {
12
+ 'CreateRole': 'RoleName AssumeRolePolicyDocument', 'GetRole': 'RoleName', 'ListRoles': '', 'DeleteRole': 'RoleName',
13
+ 'PutRolePolicy': 'RoleName PolicyName PolicyDocument', 'GetRolePolicy': 'RoleName PolicyName', 'DeleteRolePolicy': 'RoleName PolicyName',
14
+ 'ListRolePolicies': 'RoleName', 'SimulatePrincipalPolicy': 'PolicySourceArn ActionNames ResourceArns',
15
+ },
16
+ 'dynamodb': {
17
+ 'CreateTable': 'TableName AttributeDefinitions KeySchema BillingMode ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification',
18
+ 'DescribeTable': 'TableName', 'DeleteTable': 'TableName', 'ListTables': 'Limit ExclusiveStartTableName',
19
+ 'PutItem': 'TableName Item ConditionExpression ExpressionAttributeNames ExpressionAttributeValues ReturnValues',
20
+ 'GetItem': 'TableName Key ConsistentRead ProjectionExpression ExpressionAttributeNames',
21
+ 'DeleteItem': 'TableName Key ConditionExpression ExpressionAttributeNames ExpressionAttributeValues ReturnValues',
22
+ 'UpdateItem': 'TableName Key UpdateExpression ConditionExpression ExpressionAttributeNames ExpressionAttributeValues ReturnValues',
23
+ 'Query': 'TableName IndexName KeyConditionExpression FilterExpression ExpressionAttributeNames ExpressionAttributeValues Limit ExclusiveStartKey ScanIndexForward ConsistentRead Select ProjectionExpression',
24
+ 'Scan': 'TableName IndexName FilterExpression ExpressionAttributeNames ExpressionAttributeValues Limit ExclusiveStartKey ConsistentRead Select ProjectionExpression',
25
+ 'BatchGetItem': 'RequestItems', 'BatchWriteItem': 'RequestItems',
26
+ },
27
+ 's3api': {
28
+ 'CreateBucket': 'Bucket CreateBucketConfiguration', 'DeleteBucket': 'Bucket', 'ListBuckets': '', 'HeadBucket': 'Bucket',
29
+ 'ListObjectsV2': 'Bucket Prefix MaxKeys ContinuationToken Delimiter StartAfter', 'PutObject': 'Bucket Key Body ContentType Metadata',
30
+ 'GetObject': 'Bucket Key ChecksumMode', 'HeadObject': 'Bucket Key ChecksumMode', 'DeleteObject': 'Bucket Key',
31
+ },
32
+ 'sqs': {
33
+ 'CreateQueue': 'QueueName Attributes', 'GetQueueUrl': 'QueueName', 'ListQueues': 'QueueNamePrefix MaxResults NextToken', 'DeleteQueue': 'QueueUrl',
34
+ 'GetQueueAttributes': 'QueueUrl AttributeNames', 'SetQueueAttributes': 'QueueUrl Attributes',
35
+ 'SendMessage': 'QueueUrl MessageBody DelaySeconds MessageAttributes', 'ReceiveMessage': 'QueueUrl MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds MessageAttributeNames AttributeNames',
36
+ 'DeleteMessage': 'QueueUrl ReceiptHandle', 'ChangeMessageVisibility': 'QueueUrl ReceiptHandle VisibilityTimeout',
37
+ },
38
+ 'ssm': { 'PutParameter': 'Name Value Type Overwrite Description', 'GetParameter': 'Name WithDecryption', 'GetParameters': 'Names WithDecryption', 'DeleteParameter': 'Name' },
39
+ }
40
+ JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes'.split())
41
+ LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames'.split())
42
+ BOOL_FIELDS = set('ConsistentRead ScanIndexForward WithDecryption Overwrite Enabled'.split())
43
+ INT_FIELDS = set('Limit MaxKeys MaxResults MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds DelaySeconds BatchSize'.split())
44
+
45
+
46
+ def dashed(value):
47
+ return re.sub(r'(?<!^)(?=[A-Z])', '-', value).lower().replace('v-2', 'v2')
48
+
49
+
50
+ def read_json(value):
51
+ if value.startswith('file://'):
52
+ p = Path(value[7:]); require(p.is_file() and p.stat().st_size <= 1024**2, 'INVALID_INPUT', 'JSON 檔案上限為 1 MiB。')
53
+ value = p.read_text()
54
+ return json.loads(value)
55
+
56
+
57
+ def command(argv, Parser):
58
+ p = Parser(prog='aws-fufu', description='FUFU IAM 與資料服務子集;原生欄位、預設拒絕與 explicit Deny。')
59
+ services = p.add_subparsers(dest='service', required=True)
60
+ for service, ops in AWS_OPERATIONS.items():
61
+ sub = services.add_parser(service).add_subparsers(dest='operation', required=True)
62
+ for op, spec in ops.items():
63
+ q = sub.add_parser(dashed(op)); q.set_defaults(native_operation=op)
64
+ q.add_argument('--cli-input-json')
65
+ for key in spec.split():
66
+ opt = '--' + dashed(key)
67
+ if key in BOOL_FIELDS:
68
+ group = q.add_mutually_exclusive_group()
69
+ group.add_argument(opt, dest=key, action='store_true', default=None)
70
+ group.add_argument('--no-' + dashed(key), dest=key, action='store_false')
71
+ else:
72
+ q.add_argument(opt, dest=key, nargs='+' if key in LIST_FIELDS or key in {'AttributeDefinitions', 'KeySchema'} else None,
73
+ type=int if key in INT_FIELDS else str)
74
+ if op == 'CreateTable': q.add_argument('--partition-key')
75
+ if op == 'GetObject': q.add_argument('outfile')
76
+ if op == 'PutParameter': q.add_argument('--value-env')
77
+ a = p.parse_args(argv)
78
+ params = read_json(a.cli_input_json) if a.cli_input_json else {}
79
+ require(isinstance(params, dict), 'INVALID_INPUT', '原生 CLI JSON 必須是物件。')
80
+ for key in AWS_OPERATIONS[a.service][a.native_operation].split():
81
+ value = getattr(a, key)
82
+ if value is None: continue
83
+ require(key not in params, 'INVALID_INPUT', '同一欄位不能同時由 JSON 與旗標指定。')
84
+ if key in {'AttributeDefinitions', 'KeySchema'}:
85
+ 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]
86
+ elif key in JSON_FIELDS and (key != 'Key' or a.service == 'dynamodb'): value = read_json(value)
87
+ if key == 'Body':
88
+ file = Path(value.removeprefix('fileb://').removeprefix('file://'))
89
+ require(file.is_file() and file.stat().st_size <= 6 * 1024**2, 'INVALID_INPUT', 'Object 檔案上限為 6 MiB。')
90
+ value = {'__fufu_bytes': base64.b64encode(file.read_bytes()).decode()}
91
+ params[key] = value
92
+ if getattr(a, 'partition_key', None):
93
+ require(not {'AttributeDefinitions', 'KeySchema'} & params.keys(), 'INVALID_INPUT', 'partition-key 與原生 key schema 不能混用。')
94
+ params.update(AttributeDefinitions=[{'AttributeName': a.partition_key, 'AttributeType': 'S'}], KeySchema=[{'AttributeName': a.partition_key, 'KeyType': 'HASH'}])
95
+ if a.native_operation == 'CreateTable' and not {'AttributeDefinitions', 'KeySchema'} & params.keys():
96
+ params.update(AttributeDefinitions=[{'AttributeName': 'id', 'AttributeType': 'S'}], KeySchema=[{'AttributeName': 'id', 'KeyType': 'HASH'}])
97
+ if a.native_operation == 'PutParameter':
98
+ if a.value_env:
99
+ require('Value' not in params, 'INVALID_INPUT', 'value 與 value-env 不能混用。')
100
+ require(a.value_env in os.environ, 'MISSING_ENV', '必要的受控環境變數尚未注入。')
101
+ params['Value'] = os.environ[a.value_env]
102
+ params.setdefault('Overwrite', False)
103
+ params.setdefault('Type', 'String')
104
+ if a.service == 'sqs' and 'QueueUrl' in params:
105
+ require(isinstance(params['QueueUrl'], str) and re.fullmatch(r'fufu://sqs/[A-Za-z0-9_-]{1,80}', params['QueueUrl']), 'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU 租戶資源識別。')
106
+ with ApiClient('aws', read_only=bool(re.match(r'^(Get|List|Describe|Query|Scan|BatchGet|Simulate|Head)', a.native_operation))) as api:
107
+ result = api.request('POST', '/control/native', {'service': 's3' if a.service == 's3api' else a.service, 'operation': a.native_operation, 'params': params})
108
+ if a.native_operation == 'ReceiveMessage': result.setdefault('Messages', [])
109
+ if a.native_operation == 'ListQueues': result.setdefault('QueueUrls', [])
110
+ if a.native_operation == 'GetObject':
111
+ from .cloud_data import download_bytes
112
+ saved = download_bytes(a.outfile, result.pop('Body')['__fufu_bytes'])
113
+ return {**result, **saved}
114
+ return result
115
+
116
+
117
+ def gcp_command(argv, Parser):
118
+ p = Parser(prog='gcloud-fufu', description='GCP service account 與 project IAM binding 的實際授權子集。')
119
+ roots = p.add_subparsers(dest='service', required=True)
120
+ sub = roots.add_parser('iam').add_subparsers(dest='group', required=True).add_parser('service-accounts').add_subparsers(dest='action', required=True)
121
+ sub.add_parser('list')
122
+ c = sub.add_parser('create'); c.add_argument('name'); c.add_argument('--display-name')
123
+ sub.add_parser('delete').add_argument('email')
124
+ sub = roots.add_parser('projects').add_subparsers(dest='action', required=True)
125
+ for act in ['get-iam-policy', 'add-iam-policy-binding', 'remove-iam-policy-binding']:
126
+ q = sub.add_parser(act); q.add_argument('project')
127
+ if act != 'get-iam-policy': q.add_argument('--member', required=True); q.add_argument('--role', required=True)
128
+ a = p.parse_args(argv)
129
+ op = {'get-iam-policy': 'list', 'add-iam-policy-binding': 'add-binding', 'remove-iam-policy-binding': 'remove-binding'}.get(a.action, a.action)
130
+ params = {k: getattr(a, k) for k in ['name', 'email', 'project', 'member', 'role'] if hasattr(a, k)}
131
+ if getattr(a, 'display_name', None): params['displayName'] = a.display_name
132
+ with ApiClient('gcp') as api: return api.request('POST', '/control/iam', {'operation': op, 'params': params})
133
+
134
+
135
+ def gateway_command(argv, Parser):
136
+ p = Parser(prog='aws-fufu apigatewayv2', description='Lambda proxy payload 1.0;租戶登入 claims 明示為 mock。')
137
+ s = p.add_subparsers(dest='action', required=True)
138
+ for act in ['import-api', 'reimport-api']:
139
+ q = s.add_parser(act); q.add_argument('--body', required=True)
140
+ if act == 'reimport-api': q.add_argument('--api-id', required=True)
141
+ s.add_parser('get-apis')
142
+ s.add_parser('delete-api').add_argument('--api-id', required=True)
143
+ a = p.parse_args(argv)
144
+ with ApiClient('aws') as api:
145
+ if a.action == 'get-apis': return api.request('GET', '/control/apis')
146
+ if a.action == 'delete-api': return api.request('DELETE', '/control/apis/' + segment(a.api_id))
147
+ body = {'document': read_json(a.body)}
148
+ if a.action == 'reimport-api': body['id'] = a.api_id
149
+ return api.request('POST', '/control/apis', body)
150
+
151
+
152
+ def mapping_command(argv, Parser):
153
+ p = Parser(prog='aws-fufu lambda', description='SQS mapping;使用 fufu events drain 執行有限批次。')
154
+ s = p.add_subparsers(dest='action', required=True)
155
+ q = s.add_parser('create-event-source-mapping'); q.add_argument('--function-name', required=True); q.add_argument('--event-source-arn', required=True); q.add_argument('--batch-size', type=int, default=1)
156
+ s.add_parser('list-event-source-mappings')
157
+ s.add_parser('delete-event-source-mapping').add_argument('--uuid', required=True)
158
+ a = p.parse_args(argv)
159
+ with ApiClient('aws') as api:
160
+ if a.action == 'list-event-source-mappings': return api.request('GET', '/control/mappings')
161
+ if a.action == 'delete-event-source-mapping': return api.request('DELETE', '/control/mappings/' + segment(a.uuid))
162
+ return api.request('POST', '/control/mappings', {'FunctionName': a.function_name, 'EventSourceArn': a.event_source_arn, 'BatchSize': a.batch_size})
163
+
164
+
165
+ def fufu_control(argv, Parser):
166
+ p = Parser(prog='fufu', description='FUFU 明示簡化控制與 mock 目錄。')
167
+ s = p.add_subparsers(dest='group', required=True)
168
+ q = s.add_parser('gateway').add_subparsers(dest='action', required=True).add_parser('invoke')
169
+ q.add_argument('--api-id', required=True); q.add_argument('--path', required=True); q.add_argument('--method', default='POST'); q.add_argument('--body', default='{}')
170
+ q = s.add_parser('events').add_subparsers(dest='action', required=True).add_parser('drain'); q.add_argument('--uuid', required=True)
171
+ sub = s.add_parser('mock').add_subparsers(dest='action', required=True)
172
+ for act in ['create', 'list', 'delete']:
173
+ q = sub.add_parser(act); q.add_argument('--provider', required=True, choices=['aws', 'gcp', 'azure'])
174
+ if act != 'list': q.add_argument('--service', required=True); q.add_argument('--name', required=True)
175
+ if act == 'create': q.add_argument('--acknowledge-mock', action='store_true', required=True)
176
+ a = p.parse_args(argv)
177
+ with ApiClient(getattr(a, 'provider', 'aws')) as api:
178
+ if a.group == 'gateway':
179
+ result = api.request('POST', '/control/apis/' + segment(a.api_id) + '/invoke', {'method': a.method, 'path': a.path, 'body': json.dumps(read_json(a.body))})
180
+ require(result['status'] < 400, 'GATEWAY_HANDLER_FAILED', 'API handler 回傳錯誤;請核對測試資料與授權。')
181
+ return result
182
+ if a.group == 'events': return api.request('POST', '/control/mappings/' + segment(a.uuid) + '/drain', {})
183
+ if a.action == 'list': return {'resources': api.request('GET', '/control/status')['mocks'], 'mode': 'mock-only', 'executable': False}
184
+ if a.action == 'delete': return api.request('DELETE', '/control/mocks/' + segment(a.service) + '/' + segment(a.name))
185
+ return api.request('POST', '/control/mocks', {'name': a.name, 'service': a.service, 'acknowledgeMock': True})