awslabs.dynamodb-mcp-server 2.0.10__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.
- awslabs/__init__.py +17 -0
- awslabs/dynamodb_mcp_server/__init__.py +17 -0
- awslabs/dynamodb_mcp_server/cdk_generator/__init__.py +19 -0
- awslabs/dynamodb_mcp_server/cdk_generator/generator.py +276 -0
- awslabs/dynamodb_mcp_server/cdk_generator/models.py +521 -0
- awslabs/dynamodb_mcp_server/cdk_generator/templates/README.md +57 -0
- awslabs/dynamodb_mcp_server/cdk_generator/templates/stack.ts.j2 +70 -0
- awslabs/dynamodb_mcp_server/common.py +94 -0
- awslabs/dynamodb_mcp_server/db_analyzer/__init__.py +30 -0
- awslabs/dynamodb_mcp_server/db_analyzer/analyzer_utils.py +394 -0
- awslabs/dynamodb_mcp_server/db_analyzer/base_plugin.py +355 -0
- awslabs/dynamodb_mcp_server/db_analyzer/mysql.py +450 -0
- awslabs/dynamodb_mcp_server/db_analyzer/plugin_registry.py +73 -0
- awslabs/dynamodb_mcp_server/db_analyzer/postgresql.py +215 -0
- awslabs/dynamodb_mcp_server/db_analyzer/sqlserver.py +255 -0
- awslabs/dynamodb_mcp_server/markdown_formatter.py +513 -0
- awslabs/dynamodb_mcp_server/model_validation_utils.py +845 -0
- awslabs/dynamodb_mcp_server/prompts/dynamodb_architect.md +851 -0
- awslabs/dynamodb_mcp_server/prompts/json_generation_guide.md +185 -0
- awslabs/dynamodb_mcp_server/prompts/transform_model_validation_result.md +168 -0
- awslabs/dynamodb_mcp_server/server.py +524 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/METADATA +306 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/RECORD +27 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/WHEEL +4 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/entry_points.txt +2 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/LICENSE +175 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import boto3
|
|
16
|
+
import os
|
|
17
|
+
import psutil
|
|
18
|
+
import shutil
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tarfile
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
27
|
+
from loguru import logger
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
from urllib.parse import urlparse
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DynamoDBLocalConfig:
|
|
34
|
+
"""Configuration constants for DynamoDB Local setup."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_PORT = 8000
|
|
37
|
+
CONTAINER_NAME = 'dynamodb-local-setup-for-data-model-validation'
|
|
38
|
+
DOCKER_IMAGE = 'amazon/dynamodb-local'
|
|
39
|
+
DOWNLOAD_URL = 'https://d1ni2b6xgvw0s0.cloudfront.net/v2.x/dynamodb_local_latest.tar.gz'
|
|
40
|
+
TAR_FILENAME = 'dynamodb_local_latest.tar.gz'
|
|
41
|
+
TEMP_DIR_NAME = 'dynamodb-local-model-validation'
|
|
42
|
+
MAX_ATTEMPTS = 7
|
|
43
|
+
SLEEP_INTERVAL = 5
|
|
44
|
+
JAVA_PROPERTY_NAME = CONTAINER_NAME.replace('-', '.')
|
|
45
|
+
DOWNLOAD_TIMEOUT = 30
|
|
46
|
+
BATCH_SIZE = 25
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContainerTools:
|
|
50
|
+
"""Supported container tools in order of preference."""
|
|
51
|
+
|
|
52
|
+
TOOLS = ['docker', 'finch', 'podman', 'nerdctl']
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DynamoDBClientConfig:
|
|
56
|
+
"""Configuration for DynamoDB client setup."""
|
|
57
|
+
|
|
58
|
+
DUMMY_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE' # pragma: allowlist secret
|
|
59
|
+
DUMMY_SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' # pragma: allowlist secret
|
|
60
|
+
DEFAULT_REGION = 'us-east-1'
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _create_dynamodb_client(endpoint_url: Optional[str] = None):
|
|
64
|
+
"""Create a DynamoDB client with appropriate configuration.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
endpoint_url: Optional endpoint URL for local DynamoDB
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
boto3.client: Configured DynamoDB client
|
|
71
|
+
"""
|
|
72
|
+
client_kwargs = {'endpoint_url': endpoint_url} if endpoint_url else {}
|
|
73
|
+
if endpoint_url:
|
|
74
|
+
client_kwargs.update(
|
|
75
|
+
{
|
|
76
|
+
'aws_access_key_id': DynamoDBClientConfig.DUMMY_ACCESS_KEY, # pragma: allowlist secret
|
|
77
|
+
'aws_secret_access_key': DynamoDBClientConfig.DUMMY_SECRET_KEY, # pragma: allowlist secret
|
|
78
|
+
'region_name': os.environ.get('AWS_REGION', DynamoDBClientConfig.DEFAULT_REGION),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return boto3.client('dynamodb', **client_kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_subprocess_safely(
|
|
86
|
+
cmd: list, timeout: int = 5, **kwargs
|
|
87
|
+
) -> Optional[subprocess.CompletedProcess]:
|
|
88
|
+
"""Run subprocess with consistent error handling.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
cmd: Command to execute
|
|
92
|
+
timeout: Timeout in seconds
|
|
93
|
+
**kwargs: Additional subprocess arguments
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Optional[subprocess.CompletedProcess]: Result if successful, None if failed
|
|
97
|
+
"""
|
|
98
|
+
# Safeguards against direct calls
|
|
99
|
+
if not cmd or not isinstance(cmd, list):
|
|
100
|
+
logger.warning('Invalid command format')
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Restrict to only allowed commands used in this codebase
|
|
104
|
+
allowed_commands = {
|
|
105
|
+
'docker',
|
|
106
|
+
'finch',
|
|
107
|
+
'podman',
|
|
108
|
+
'nerdctl', # Container tools
|
|
109
|
+
'java', # Java executable
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Extract base command name (handle both full paths and command names)
|
|
113
|
+
base_cmd = os.path.basename(cmd[0]) if cmd else ''
|
|
114
|
+
|
|
115
|
+
# Remove .exe extension for Windows compatibility
|
|
116
|
+
if base_cmd.endswith('.exe'):
|
|
117
|
+
base_cmd = base_cmd[:-4]
|
|
118
|
+
|
|
119
|
+
if base_cmd not in allowed_commands:
|
|
120
|
+
logger.warning(f'Command not allowed: {base_cmd}')
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return subprocess.run(
|
|
125
|
+
cmd, check=True, timeout=timeout, capture_output=True, text=True, **kwargs
|
|
126
|
+
)
|
|
127
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
128
|
+
logger.debug(f'Subprocess failed: {cmd[0]} - {e}')
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_container_port(ports_output: str) -> Optional[str]:
|
|
133
|
+
"""Parse port from container port mapping output.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
ports_output: Container port mapping string (e.g., "0.0.0.0:8001->8000/tcp")
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Optional[str]: Host port if found, None otherwise
|
|
140
|
+
"""
|
|
141
|
+
if '->' in ports_output:
|
|
142
|
+
return ports_output.split('->')[0].split(':')[-1]
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _container_exists(container_path: str) -> bool:
|
|
147
|
+
"""Check if container exists (running or stopped)."""
|
|
148
|
+
check_cmd = [
|
|
149
|
+
container_path,
|
|
150
|
+
'ps',
|
|
151
|
+
'-a',
|
|
152
|
+
'-q',
|
|
153
|
+
'-f',
|
|
154
|
+
f'name={DynamoDBLocalConfig.CONTAINER_NAME}',
|
|
155
|
+
]
|
|
156
|
+
result = _run_subprocess_safely(check_cmd)
|
|
157
|
+
return result is not None and result.stdout.strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _container_is_running(container_path: str) -> bool:
|
|
161
|
+
"""Check if container is currently running."""
|
|
162
|
+
running_cmd = [container_path, 'ps', '-q', '-f', f'name={DynamoDBLocalConfig.CONTAINER_NAME}']
|
|
163
|
+
result = _run_subprocess_safely(running_cmd)
|
|
164
|
+
return result is not None and result.stdout.strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _restart_container(container_path: str) -> bool:
|
|
168
|
+
"""Restart a stopped container."""
|
|
169
|
+
logger.info(f'Restarting stopped container: {DynamoDBLocalConfig.CONTAINER_NAME}')
|
|
170
|
+
restart_cmd = [container_path, 'start', DynamoDBLocalConfig.CONTAINER_NAME]
|
|
171
|
+
result = _run_subprocess_safely(restart_cmd)
|
|
172
|
+
return result is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_container_port(container_path: str) -> Optional[str]:
|
|
176
|
+
"""Get the host port for the container."""
|
|
177
|
+
ports_cmd = [
|
|
178
|
+
container_path,
|
|
179
|
+
'ps',
|
|
180
|
+
'--format',
|
|
181
|
+
'{{.Ports}}',
|
|
182
|
+
'-f',
|
|
183
|
+
f'name={DynamoDBLocalConfig.CONTAINER_NAME}',
|
|
184
|
+
]
|
|
185
|
+
result = _run_subprocess_safely(ports_cmd)
|
|
186
|
+
|
|
187
|
+
if result and result.stdout.strip():
|
|
188
|
+
return _parse_container_port(result.stdout.strip())
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _extract_port_from_cmdline(cmdline: list) -> Optional[int]:
|
|
193
|
+
"""Extract port number from Java command line arguments.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
cmdline: List of command line arguments from process
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Optional[int]: Port number if found and valid, None otherwise
|
|
200
|
+
"""
|
|
201
|
+
for i, arg in enumerate(cmdline):
|
|
202
|
+
if arg == '-port' and i + 1 < len(cmdline):
|
|
203
|
+
try:
|
|
204
|
+
return int(cmdline[i + 1])
|
|
205
|
+
except ValueError:
|
|
206
|
+
return None
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _safe_extract_members(members):
|
|
211
|
+
"""Filter tar members to prevent path traversal attacks.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
members: Iterable of tar members
|
|
215
|
+
|
|
216
|
+
Yields:
|
|
217
|
+
Safe tar members that don't contain path traversal sequences
|
|
218
|
+
"""
|
|
219
|
+
for member in members:
|
|
220
|
+
if os.path.isabs(member.name) or '..' in member.name:
|
|
221
|
+
continue
|
|
222
|
+
yield member
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _get_dynamodb_local_paths() -> tuple[str, str, str]:
|
|
226
|
+
"""Get paths for DynamoDB Local artifacts."""
|
|
227
|
+
dynamodb_dir = os.path.join(tempfile.gettempdir(), DynamoDBLocalConfig.TEMP_DIR_NAME)
|
|
228
|
+
jar_path = os.path.join(dynamodb_dir, 'DynamoDBLocal.jar')
|
|
229
|
+
lib_path = os.path.join(dynamodb_dir, 'DynamoDBLocal_lib')
|
|
230
|
+
return dynamodb_dir, jar_path, lib_path
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_download_url(url: str) -> None:
|
|
234
|
+
"""Validate download URL to prevent security issues.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
url: URL to validate
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ValueError: If URL is not safe to use
|
|
241
|
+
"""
|
|
242
|
+
# Only allow the exact DynamoDB Local download URL
|
|
243
|
+
if url != DynamoDBLocalConfig.DOWNLOAD_URL:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f'Only DynamoDB Local download URL is allowed: {DynamoDBLocalConfig.DOWNLOAD_URL}'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _download_and_extract_jar(dynamodb_dir: str, jar_path: str, lib_path: str) -> None:
|
|
250
|
+
"""Download and extract DynamoDB Local JAR."""
|
|
251
|
+
tar_path = os.path.join(dynamodb_dir, DynamoDBLocalConfig.TAR_FILENAME)
|
|
252
|
+
|
|
253
|
+
logger.info('Downloading DynamoDB Local...')
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# Validate URL before download
|
|
257
|
+
_validate_download_url(DynamoDBLocalConfig.DOWNLOAD_URL)
|
|
258
|
+
|
|
259
|
+
# Download with timeout
|
|
260
|
+
with urllib.request.urlopen( # nosec B310
|
|
261
|
+
DynamoDBLocalConfig.DOWNLOAD_URL, timeout=DynamoDBLocalConfig.DOWNLOAD_TIMEOUT
|
|
262
|
+
) as response:
|
|
263
|
+
# Validate content type
|
|
264
|
+
content_type = response.headers.get('content-type', '')
|
|
265
|
+
if content_type and not content_type.startswith(
|
|
266
|
+
(
|
|
267
|
+
'application/gzip',
|
|
268
|
+
'application/x-gzip',
|
|
269
|
+
'application/octet-stream',
|
|
270
|
+
'application/x-tar',
|
|
271
|
+
)
|
|
272
|
+
):
|
|
273
|
+
raise ValueError(f'Unexpected content type: {content_type}')
|
|
274
|
+
|
|
275
|
+
with open(tar_path, 'wb') as f:
|
|
276
|
+
f.write(response.read())
|
|
277
|
+
|
|
278
|
+
# Validate tar contents before extraction
|
|
279
|
+
with tarfile.open(tar_path, 'r:gz') as tar:
|
|
280
|
+
if 'DynamoDBLocal.jar' not in tar.getnames():
|
|
281
|
+
raise RuntimeError('DynamoDBLocal.jar not found in archive')
|
|
282
|
+
|
|
283
|
+
if hasattr(tarfile, 'data_filter'):
|
|
284
|
+
tar.extractall(dynamodb_dir, members=_safe_extract_members(tar), filter='data') # nosec B202
|
|
285
|
+
else:
|
|
286
|
+
tar.extractall(dynamodb_dir, members=_safe_extract_members(tar)) # nosec B202
|
|
287
|
+
|
|
288
|
+
# Clean up tar file
|
|
289
|
+
os.remove(tar_path)
|
|
290
|
+
|
|
291
|
+
logger.info(f'Downloaded and extracted DynamoDB Local to {jar_path}')
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
if os.path.exists(dynamodb_dir):
|
|
295
|
+
shutil.rmtree(dynamodb_dir)
|
|
296
|
+
raise RuntimeError(f'Failed to download DynamoDB Local: {e}')
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _try_container_setup() -> Optional[str]:
|
|
300
|
+
"""Try to setup DynamoDB Local using container tools."""
|
|
301
|
+
container_path = get_container_path()
|
|
302
|
+
if not container_path:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
# Check if our container is already running
|
|
307
|
+
existing_endpoint = get_existing_container_dynamodb_local_endpoint(container_path)
|
|
308
|
+
if existing_endpoint:
|
|
309
|
+
return existing_endpoint
|
|
310
|
+
|
|
311
|
+
# Find available port and start container
|
|
312
|
+
port = find_available_port(DynamoDBLocalConfig.DEFAULT_PORT)
|
|
313
|
+
return start_container(container_path, port)
|
|
314
|
+
|
|
315
|
+
except RuntimeError as e:
|
|
316
|
+
logger.debug(f'Container setup failed: {e}')
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _try_java_setup() -> Optional[str]:
|
|
321
|
+
"""Try to setup DynamoDB Local using Java."""
|
|
322
|
+
java_path = get_java_path()
|
|
323
|
+
if not java_path:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
# Check if our Java process is already running
|
|
328
|
+
existing_endpoint = get_existing_java_dynamodb_local_endpoint()
|
|
329
|
+
if existing_endpoint:
|
|
330
|
+
return existing_endpoint
|
|
331
|
+
|
|
332
|
+
# Find available port and start Java process
|
|
333
|
+
port = find_available_port(DynamoDBLocalConfig.DEFAULT_PORT)
|
|
334
|
+
return start_java_process(java_path, port)
|
|
335
|
+
|
|
336
|
+
except RuntimeError as e:
|
|
337
|
+
logger.debug(f'Java setup failed: {e}')
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_container_path() -> Optional[str]:
|
|
342
|
+
"""Get Docker-compatible executable path with running daemon.
|
|
343
|
+
|
|
344
|
+
Searches for available container tools (Docker, Podman, Finch, nerdctl) and
|
|
345
|
+
returns the first one with a running daemon. Tests daemon connectivity by
|
|
346
|
+
running 'tool ps' command.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Optional[str]: Path to working container tool executable, or None if no
|
|
350
|
+
working tool is found.
|
|
351
|
+
"""
|
|
352
|
+
errors = []
|
|
353
|
+
|
|
354
|
+
for tool in ContainerTools.TOOLS:
|
|
355
|
+
path = shutil.which(tool)
|
|
356
|
+
if path:
|
|
357
|
+
result = _run_subprocess_safely([path, 'ps'])
|
|
358
|
+
if result:
|
|
359
|
+
return path
|
|
360
|
+
else:
|
|
361
|
+
errors.append(f'{tool}: daemon not running or not accessible')
|
|
362
|
+
|
|
363
|
+
if errors:
|
|
364
|
+
logger.debug(f'Container tool errors: {"; ".join(errors)}')
|
|
365
|
+
else:
|
|
366
|
+
logger.debug('No container tools found')
|
|
367
|
+
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def find_available_port(start_port: int = DynamoDBLocalConfig.DEFAULT_PORT) -> int:
|
|
372
|
+
"""Find an available port for DynamoDB Local.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
start_port: Preferred port number to try first
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
int: Available port number that can be used for binding
|
|
379
|
+
"""
|
|
380
|
+
# First try the requested port
|
|
381
|
+
try:
|
|
382
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
383
|
+
sock.bind(('localhost', start_port))
|
|
384
|
+
return start_port
|
|
385
|
+
except OSError:
|
|
386
|
+
# Requested port not available, let kernel assign one
|
|
387
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
388
|
+
sock.bind(('localhost', 0))
|
|
389
|
+
_, port = sock.getsockname()
|
|
390
|
+
return port
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def get_existing_container_dynamodb_local_endpoint(container_path: str) -> Optional[str]:
|
|
394
|
+
"""Check if DynamoDB Local container exists and return its endpoint.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
container_path: Path to container tool executable
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Optional[str]: DynamoDB Local endpoint URL if container exists and is
|
|
401
|
+
accessible, None otherwise.
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
if not _container_exists(container_path):
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
# Ensure container is running
|
|
408
|
+
if not _container_is_running(container_path):
|
|
409
|
+
if not _restart_container(container_path):
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
# Get port mapping
|
|
413
|
+
host_port = _get_container_port(container_path)
|
|
414
|
+
if host_port:
|
|
415
|
+
endpoint = f'http://localhost:{host_port}'
|
|
416
|
+
logger.info(f'DynamoDB Local container available at {endpoint}')
|
|
417
|
+
return endpoint
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.debug(f'Error checking for existing container: {e}')
|
|
421
|
+
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def start_container(container_path: str, port: int) -> str:
|
|
426
|
+
"""Start DynamoDB Local container and verify readiness.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
container_path: Path to container tool executable
|
|
430
|
+
port: Host port to map to container's DynamoDB port
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
str: DynamoDB Local endpoint URL (http://localhost:port)
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
RuntimeError: If container fails to start or become ready within timeout
|
|
437
|
+
"""
|
|
438
|
+
cmd = [
|
|
439
|
+
container_path,
|
|
440
|
+
'run',
|
|
441
|
+
'-d',
|
|
442
|
+
'--name',
|
|
443
|
+
DynamoDBLocalConfig.CONTAINER_NAME,
|
|
444
|
+
'-p',
|
|
445
|
+
f'127.0.0.1:{port}:{DynamoDBLocalConfig.DEFAULT_PORT}',
|
|
446
|
+
DynamoDBLocalConfig.DOCKER_IMAGE,
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
logger.info(f'Starting DynamoDB Local container on port {port}')
|
|
450
|
+
result = _run_subprocess_safely(cmd, timeout=30)
|
|
451
|
+
|
|
452
|
+
if not result:
|
|
453
|
+
raise RuntimeError('Failed to start Docker container')
|
|
454
|
+
|
|
455
|
+
endpoint = f'http://localhost:{port}'
|
|
456
|
+
return check_dynamodb_readiness(endpoint)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def get_java_path() -> Optional[str]:
|
|
460
|
+
"""Get Java executable path using cross-platform approach.
|
|
461
|
+
|
|
462
|
+
Attempts to locate Java executable by:
|
|
463
|
+
1. Checking JAVA_HOME environment variable and validating executable exists and is runnable
|
|
464
|
+
2. Falling back to searching system PATH for 'java' command
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Optional[str]: Full path to Java executable if found and executable, None otherwise
|
|
468
|
+
"""
|
|
469
|
+
# 1. Check JAVA_HOME environment variable
|
|
470
|
+
java_home = os.environ.get('JAVA_HOME')
|
|
471
|
+
if java_home:
|
|
472
|
+
# Determine executable name based on platform
|
|
473
|
+
java_executable = 'java.exe' if sys.platform == 'win32' else 'java'
|
|
474
|
+
java_exe = os.path.join(java_home, 'bin', java_executable)
|
|
475
|
+
if os.path.isfile(java_exe) and os.access(java_exe, os.X_OK):
|
|
476
|
+
return java_exe
|
|
477
|
+
|
|
478
|
+
# 2. Fall back to searching PATH
|
|
479
|
+
return shutil.which('java')
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def get_existing_java_dynamodb_local_endpoint() -> Optional[str]:
|
|
483
|
+
"""Check if DynamoDB Local Java process is already running and return its endpoint.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Optional[str]: DynamoDB Local endpoint URL (http://localhost:port) if found, None otherwise
|
|
487
|
+
|
|
488
|
+
Note:
|
|
489
|
+
Only detects processes started by this tool with the specific system property
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
# Find Java processes with our system property
|
|
493
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
494
|
+
try:
|
|
495
|
+
if proc.info['name'] and 'java' in proc.info['name'].lower():
|
|
496
|
+
cmdline = proc.info['cmdline']
|
|
497
|
+
if cmdline and any(
|
|
498
|
+
DynamoDBLocalConfig.JAVA_PROPERTY_NAME in arg for arg in cmdline
|
|
499
|
+
):
|
|
500
|
+
port = _extract_port_from_cmdline(cmdline)
|
|
501
|
+
if port:
|
|
502
|
+
endpoint = f'http://localhost:{port}'
|
|
503
|
+
logger.info(
|
|
504
|
+
f'Found existing DynamoDB Local Java process at {endpoint}'
|
|
505
|
+
)
|
|
506
|
+
return endpoint
|
|
507
|
+
except (
|
|
508
|
+
psutil.NoSuchProcess,
|
|
509
|
+
psutil.AccessDenied,
|
|
510
|
+
psutil.ZombieProcess,
|
|
511
|
+
ValueError,
|
|
512
|
+
IndexError,
|
|
513
|
+
) as e:
|
|
514
|
+
logger.debug(
|
|
515
|
+
f'Error processing Java process {proc.info.get("pid", "unknown")}: {e}'
|
|
516
|
+
)
|
|
517
|
+
continue
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.debug(f'Error checking for existing Java process: {e}')
|
|
520
|
+
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def download_dynamodb_local_jar() -> tuple[str, str]:
|
|
525
|
+
"""Download DynamoDB Local JAR and return JAR path and lib path.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
tuple[str, str]: (jar_path, lib_path) where:
|
|
529
|
+
- jar_path: Full path to DynamoDBLocal.jar
|
|
530
|
+
- lib_path: Full path to DynamoDBLocal_lib directory containing native libraries
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
RuntimeError: If download fails, extraction fails, or JAR not found after extraction
|
|
534
|
+
"""
|
|
535
|
+
dynamodb_dir, jar_path, lib_path = _get_dynamodb_local_paths()
|
|
536
|
+
os.makedirs(dynamodb_dir, exist_ok=True)
|
|
537
|
+
|
|
538
|
+
# Check if both JAR and lib directory exist
|
|
539
|
+
if os.path.exists(jar_path) and os.path.exists(lib_path):
|
|
540
|
+
return jar_path, lib_path
|
|
541
|
+
|
|
542
|
+
_download_and_extract_jar(dynamodb_dir, jar_path, lib_path)
|
|
543
|
+
return jar_path, lib_path
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def start_java_process(java_path: str, port: int) -> str:
|
|
547
|
+
"""Start DynamoDB Local using Java and return endpoint URL.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
java_path: Full path to Java executable
|
|
551
|
+
port: Port number for DynamoDB Local to listen on
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
str: DynamoDB Local endpoint URL (http://localhost:port)
|
|
555
|
+
|
|
556
|
+
Raises:
|
|
557
|
+
RuntimeError: If Java process fails to start, JAR download fails, or service
|
|
558
|
+
doesn't become ready within timeout period
|
|
559
|
+
"""
|
|
560
|
+
jar_path, lib_path = download_dynamodb_local_jar()
|
|
561
|
+
|
|
562
|
+
cmd = [
|
|
563
|
+
java_path,
|
|
564
|
+
f'-D{DynamoDBLocalConfig.JAVA_PROPERTY_NAME}=true',
|
|
565
|
+
f'-Djava.library.path={lib_path}',
|
|
566
|
+
'-Djava.net.bindAddress=127.0.0.1',
|
|
567
|
+
'-jar',
|
|
568
|
+
jar_path,
|
|
569
|
+
'-port',
|
|
570
|
+
str(port),
|
|
571
|
+
'-inMemory',
|
|
572
|
+
'-sharedDb',
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
# Validate command before execution
|
|
577
|
+
base_cmd = os.path.basename(java_path)
|
|
578
|
+
if base_cmd.endswith('.exe'):
|
|
579
|
+
base_cmd = base_cmd[:-4]
|
|
580
|
+
if base_cmd != 'java':
|
|
581
|
+
raise RuntimeError(f'Invalid Java executable: {base_cmd}')
|
|
582
|
+
|
|
583
|
+
logger.info(f'Starting DynamoDB Local with Java on port {port}')
|
|
584
|
+
|
|
585
|
+
process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
586
|
+
|
|
587
|
+
time.sleep(DynamoDBLocalConfig.SLEEP_INTERVAL)
|
|
588
|
+
if process.poll() is not None:
|
|
589
|
+
_, stderr = process.communicate()
|
|
590
|
+
raise RuntimeError(f'Java process failed to start: {stderr.decode()}')
|
|
591
|
+
|
|
592
|
+
endpoint = f'http://localhost:{port}'
|
|
593
|
+
return check_dynamodb_readiness(endpoint)
|
|
594
|
+
|
|
595
|
+
except Exception as e:
|
|
596
|
+
raise RuntimeError(f'Failed to start DynamoDB Local with Java: {e}')
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def check_dynamodb_readiness(endpoint: str) -> str:
|
|
600
|
+
"""Check if DynamoDB Local is ready to accept connections and return endpoint.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
endpoint: DynamoDB Local endpoint URL (e.g., 'http://localhost:8000')
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
str: The same endpoint URL if service is ready and responding
|
|
607
|
+
|
|
608
|
+
Raises:
|
|
609
|
+
RuntimeError: If DynamoDB Local doesn't become ready within timeout period
|
|
610
|
+
"""
|
|
611
|
+
client = _create_dynamodb_client(endpoint)
|
|
612
|
+
|
|
613
|
+
for i in range(DynamoDBLocalConfig.MAX_ATTEMPTS):
|
|
614
|
+
try:
|
|
615
|
+
client.list_tables()
|
|
616
|
+
logger.info(f'DynamoDB Local ready at {endpoint}')
|
|
617
|
+
return endpoint
|
|
618
|
+
except (ClientError, EndpointConnectionError) as e:
|
|
619
|
+
if i == DynamoDBLocalConfig.MAX_ATTEMPTS - 1:
|
|
620
|
+
total_timeout = (
|
|
621
|
+
DynamoDBLocalConfig.MAX_ATTEMPTS * DynamoDBLocalConfig.SLEEP_INTERVAL
|
|
622
|
+
)
|
|
623
|
+
raise RuntimeError(
|
|
624
|
+
f'DynamoDB Local failed to start at {endpoint} after {total_timeout} seconds. '
|
|
625
|
+
f'Last error: {e}'
|
|
626
|
+
)
|
|
627
|
+
logger.debug(
|
|
628
|
+
f'DynamoDB Local not ready, retrying in {DynamoDBLocalConfig.SLEEP_INTERVAL}s (attempt {i + 1}/{DynamoDBLocalConfig.MAX_ATTEMPTS})'
|
|
629
|
+
)
|
|
630
|
+
time.sleep(DynamoDBLocalConfig.SLEEP_INTERVAL)
|
|
631
|
+
|
|
632
|
+
# This should never be reached due to the exception in the loop, but added for type safety
|
|
633
|
+
raise RuntimeError(f'DynamoDB Local failed to start at {endpoint}')
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def setup_dynamodb_local() -> str:
|
|
637
|
+
"""Setup DynamoDB Local environment.
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
str: DynamoDB Local endpoint URL
|
|
641
|
+
|
|
642
|
+
Raises:
|
|
643
|
+
RuntimeError: If neither Docker nor Java is available or setup fails
|
|
644
|
+
"""
|
|
645
|
+
# Try container setup first
|
|
646
|
+
endpoint = _try_container_setup()
|
|
647
|
+
if endpoint:
|
|
648
|
+
return endpoint
|
|
649
|
+
|
|
650
|
+
# Fallback to Java
|
|
651
|
+
endpoint = _try_java_setup()
|
|
652
|
+
if endpoint:
|
|
653
|
+
return endpoint
|
|
654
|
+
|
|
655
|
+
raise RuntimeError(
|
|
656
|
+
'No working container tool or Java found. Please install and start a container tool (Docker, Finch, Podman, or nerdctl) or install Java JRE version 17.x or newer and set JAVA_HOME or system PATH to run DynamoDB Local for data model validation.'
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def create_validation_resources(
|
|
661
|
+
resources: Dict[str, Any], endpoint_url: Optional[str] = None
|
|
662
|
+
) -> Dict[str, Any]:
|
|
663
|
+
"""Create DynamoDB resources for data model validation.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
resources: Valid dictionary containing tables and items
|
|
667
|
+
endpoint_url: DynamoDB endpoint URL
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
Dictionary with response from both table creation and item insertion
|
|
671
|
+
"""
|
|
672
|
+
dynamodb_client = _create_dynamodb_client(endpoint_url)
|
|
673
|
+
|
|
674
|
+
logger.info('Cleaning up existing tables in DynamoDB local for Model Validation')
|
|
675
|
+
cleanup_validation_resources(dynamodb_client)
|
|
676
|
+
|
|
677
|
+
tables = resources.get('tables', [])
|
|
678
|
+
items = resources.get('items', {})
|
|
679
|
+
|
|
680
|
+
# Validate data types
|
|
681
|
+
if not isinstance(tables, list):
|
|
682
|
+
tables = []
|
|
683
|
+
if not isinstance(items, dict):
|
|
684
|
+
items = {}
|
|
685
|
+
|
|
686
|
+
table_creation_response = create_tables(dynamodb_client, tables)
|
|
687
|
+
item_insertion_response = insert_items(dynamodb_client, items)
|
|
688
|
+
|
|
689
|
+
return {'tables': table_creation_response, 'items': item_insertion_response}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def cleanup_validation_resources(dynamodb_client) -> Dict[str, Any]:
|
|
693
|
+
"""Clean up all existing tables in DynamoDB Local from previous DynamoDB data model validation.
|
|
694
|
+
|
|
695
|
+
This function removes all tables that were created during previous validation runs,
|
|
696
|
+
ensuring a clean state for new data model validation operations.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
dynamodb_client: Valid boto3 DynamoDB client configured for DynamoDB Local
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
Dictionary with cleanup response for each table, containing status and messages.
|
|
703
|
+
"""
|
|
704
|
+
# SAFETY CHECK: Ensure we're only deleting from localhost
|
|
705
|
+
endpoint_url = dynamodb_client.meta.endpoint_url
|
|
706
|
+
if endpoint_url:
|
|
707
|
+
parsed = urlparse(endpoint_url)
|
|
708
|
+
hostname = parsed.hostname
|
|
709
|
+
if hostname not in ('localhost', '127.0.0.1'):
|
|
710
|
+
raise ValueError(
|
|
711
|
+
f'SAFETY VIOLATION: Table deletion must only run on localhost. '
|
|
712
|
+
f'Got endpoint: {endpoint_url}. This prevents accidental production table deletion.'
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
cleanup_response = {}
|
|
716
|
+
|
|
717
|
+
table_names = list_tables(dynamodb_client)
|
|
718
|
+
|
|
719
|
+
for table_name in table_names:
|
|
720
|
+
try:
|
|
721
|
+
dynamodb_client.delete_table(TableName=table_name)
|
|
722
|
+
cleanup_response[table_name] = {
|
|
723
|
+
'status': 'deleted',
|
|
724
|
+
'message': f'Table {table_name} deleted successfully',
|
|
725
|
+
}
|
|
726
|
+
except dynamodb_client.exceptions.ResourceNotFoundException:
|
|
727
|
+
cleanup_response[table_name] = {
|
|
728
|
+
'status': 'not_found',
|
|
729
|
+
'message': f'Table {table_name} not found',
|
|
730
|
+
}
|
|
731
|
+
except Exception as e:
|
|
732
|
+
cleanup_response[table_name] = {'status': 'error', 'error': str(e)}
|
|
733
|
+
|
|
734
|
+
return cleanup_response
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def list_tables(dynamodb_client) -> list:
|
|
738
|
+
"""List all DynamoDB tables in the local environment for data model validation.
|
|
739
|
+
|
|
740
|
+
Retrieves all table names from DynamoDB Local to support cleanup and validation operations.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
dynamodb_client: Valid boto3 DynamoDB client configured for DynamoDB Local
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
List of table names, or empty list if the operation fails.
|
|
747
|
+
"""
|
|
748
|
+
try:
|
|
749
|
+
response = dynamodb_client.list_tables()
|
|
750
|
+
return response['TableNames']
|
|
751
|
+
except Exception:
|
|
752
|
+
return []
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def create_tables(dynamodb_client, tables: list) -> Dict[str, Any]:
|
|
756
|
+
"""Create DynamoDB tables.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
dynamodb_client: Valid boto3 DynamoDB client
|
|
760
|
+
tables: Array of table configurations
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
Dictionary with table creation response for each table.
|
|
764
|
+
"""
|
|
765
|
+
table_creation_response = {}
|
|
766
|
+
|
|
767
|
+
for table_config in tables:
|
|
768
|
+
if not isinstance(table_config, dict) or 'TableName' not in table_config:
|
|
769
|
+
continue
|
|
770
|
+
table_name = table_config['TableName']
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
response = dynamodb_client.create_table(**table_config)
|
|
774
|
+
table_creation_response[table_name] = {
|
|
775
|
+
'status': 'success',
|
|
776
|
+
'table_arn': response['TableDescription']['TableArn'],
|
|
777
|
+
}
|
|
778
|
+
except dynamodb_client.exceptions.ResourceInUseException:
|
|
779
|
+
table_creation_response[table_name] = {
|
|
780
|
+
'status': 'exists',
|
|
781
|
+
'message': f'Table {table_name} already exists',
|
|
782
|
+
}
|
|
783
|
+
except Exception as e:
|
|
784
|
+
table_creation_response[table_name] = {'status': 'error', 'error': str(e)}
|
|
785
|
+
|
|
786
|
+
return table_creation_response
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def insert_items(dynamodb_client, items: dict) -> Dict[str, Any]:
|
|
790
|
+
"""Insert items into DynamoDB tables using batch_write_item.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
dynamodb_client: Valid boto3 DynamoDB client
|
|
794
|
+
items: Dictionary of table names to item lists
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
Dictionary with insertion response for each table.
|
|
798
|
+
"""
|
|
799
|
+
item_insertion_response = {}
|
|
800
|
+
|
|
801
|
+
for table_name, table_items in items.items():
|
|
802
|
+
if not isinstance(table_items, list):
|
|
803
|
+
continue
|
|
804
|
+
|
|
805
|
+
total_items = len(table_items)
|
|
806
|
+
processed_items = 0
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
# Process items in batches
|
|
810
|
+
for i in range(0, total_items, DynamoDBLocalConfig.BATCH_SIZE):
|
|
811
|
+
batch_items = table_items[i : i + DynamoDBLocalConfig.BATCH_SIZE]
|
|
812
|
+
response = dynamodb_client.batch_write_item(RequestItems={table_name: batch_items})
|
|
813
|
+
processed_items += len(batch_items) - len(
|
|
814
|
+
response.get('UnprocessedItems', {}).get(table_name, [])
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
item_insertion_response[table_name] = {
|
|
818
|
+
'status': 'success',
|
|
819
|
+
'items_processed': processed_items,
|
|
820
|
+
}
|
|
821
|
+
except Exception as e:
|
|
822
|
+
item_insertion_response[table_name] = {'status': 'error', 'error': str(e)}
|
|
823
|
+
|
|
824
|
+
return item_insertion_response
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def get_validation_result_transform_prompt() -> str:
|
|
828
|
+
"""Provides transformation prompt for converting DynamoDB access pattern validation result to markdown format.
|
|
829
|
+
|
|
830
|
+
This tool returns instructions for transforming dynamodb_model_validation.json (generated by execute_access_patterns)
|
|
831
|
+
into a comprehensive, readable markdown report. The transformation includes:
|
|
832
|
+
|
|
833
|
+
- Summary statistics of validation results
|
|
834
|
+
- Detailed breakdown of each access pattern test
|
|
835
|
+
- Success/failure indicators with clear formatting
|
|
836
|
+
- Professional markdown structure with proper code blocks
|
|
837
|
+
- Error details and troubleshooting information
|
|
838
|
+
|
|
839
|
+
Input: Reads dynamodb_model_validation.json from current working directory
|
|
840
|
+
Output: Creates dynamodb_model_validation.md and displays the formatted results
|
|
841
|
+
|
|
842
|
+
Returns: Complete transformation prompt for converting JSON validation results to markdown
|
|
843
|
+
"""
|
|
844
|
+
prompt_file = Path(__file__).parent / 'prompts' / 'transform_model_validation_result.md'
|
|
845
|
+
return prompt_file.read_text(encoding='utf-8')
|