mcp-souschef 2.0.1__py3-none-any.whl → 2.2.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.
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/METADATA +453 -77
- mcp_souschef-2.2.0.dist-info/RECORD +31 -0
- souschef/__init__.py +17 -0
- souschef/assessment.py +1498 -0
- souschef/cli.py +90 -0
- souschef/converters/__init__.py +23 -0
- souschef/converters/habitat.py +674 -0
- souschef/converters/playbook.py +1736 -0
- souschef/converters/resource.py +325 -0
- souschef/core/__init__.py +80 -0
- souschef/core/constants.py +145 -0
- souschef/core/errors.py +275 -0
- souschef/core/path_utils.py +58 -0
- souschef/core/ruby_utils.py +39 -0
- souschef/core/validation.py +555 -0
- souschef/deployment.py +1906 -0
- souschef/filesystem/__init__.py +5 -0
- souschef/filesystem/operations.py +67 -0
- souschef/parsers/__init__.py +36 -0
- souschef/parsers/attributes.py +257 -0
- souschef/parsers/habitat.py +317 -0
- souschef/parsers/inspec.py +809 -0
- souschef/parsers/metadata.py +211 -0
- souschef/parsers/recipe.py +200 -0
- souschef/parsers/resource.py +170 -0
- souschef/parsers/template.py +342 -0
- souschef/profiling.py +568 -0
- souschef/server.py +1854 -7481
- mcp_souschef-2.0.1.dist-info/RECORD +0 -8
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/licenses/LICENSE +0 -0
souschef/core/errors.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Enhanced error handling with actionable messages and recovery suggestions."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SousChefError(Exception):
|
|
7
|
+
"""Base exception for SousChef with enhanced error messages."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, suggestion: str | None = None):
|
|
10
|
+
"""
|
|
11
|
+
Initialize with message and optional recovery suggestion.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
message: The error message describing what went wrong.
|
|
15
|
+
suggestion: Optional suggestion for how to fix the error.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
self.message = message
|
|
19
|
+
self.suggestion = suggestion
|
|
20
|
+
full_message = message
|
|
21
|
+
if suggestion:
|
|
22
|
+
full_message = f"{message}\n\nSuggestion: {suggestion}"
|
|
23
|
+
super().__init__(full_message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChefFileNotFoundError(SousChefError):
|
|
27
|
+
"""Raised when a required file cannot be found."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str, file_type: str = "file"):
|
|
30
|
+
"""
|
|
31
|
+
Initialize file not found error.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: The path that was not found.
|
|
35
|
+
file_type: Type of file (e.g., 'cookbook', 'recipe', 'template').
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
message = f"Could not find {file_type}: {path}"
|
|
39
|
+
suggestion = (
|
|
40
|
+
"Check that the path exists and you have read permissions. "
|
|
41
|
+
"For cookbooks, ensure you're pointing to the cookbook root "
|
|
42
|
+
"directory containing metadata.rb."
|
|
43
|
+
)
|
|
44
|
+
super().__init__(message, suggestion)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InvalidCookbookError(SousChefError):
|
|
48
|
+
"""Raised when a cookbook is invalid or malformed."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: str, reason: str):
|
|
51
|
+
"""
|
|
52
|
+
Initialize invalid cookbook error.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
path: The cookbook path.
|
|
56
|
+
reason: Why the cookbook is invalid.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
message = f"Invalid cookbook at {path}: {reason}"
|
|
60
|
+
suggestion = (
|
|
61
|
+
"Ensure the directory contains a valid Chef cookbook with "
|
|
62
|
+
"metadata.rb. Run 'knife cookbook test' to validate the "
|
|
63
|
+
"cookbook structure."
|
|
64
|
+
)
|
|
65
|
+
super().__init__(message, suggestion)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ParseError(SousChefError):
|
|
69
|
+
"""Raised when parsing Chef code fails."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, file_path: str, line_number: int | None = None, detail: str = ""
|
|
73
|
+
):
|
|
74
|
+
"""
|
|
75
|
+
Initialize parse error.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
file_path: The file that failed to parse.
|
|
79
|
+
line_number: Optional line number where parsing failed.
|
|
80
|
+
detail: Additional detail about the parse failure.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
location = f" at line {line_number}" if line_number else ""
|
|
84
|
+
message = f"Failed to parse {file_path}{location}"
|
|
85
|
+
if detail:
|
|
86
|
+
message += f": {detail}"
|
|
87
|
+
suggestion = (
|
|
88
|
+
"Check that the file contains valid Chef Ruby DSL syntax. "
|
|
89
|
+
"Complex Ruby code may require manual review."
|
|
90
|
+
)
|
|
91
|
+
super().__init__(message, suggestion)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ConversionError(SousChefError):
|
|
95
|
+
"""Raised when conversion from Chef to Ansible fails."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, resource_type: str, reason: str):
|
|
98
|
+
"""
|
|
99
|
+
Initialize conversion error.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
resource_type: The Chef resource type that failed to convert.
|
|
103
|
+
reason: Why the conversion failed.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
message = f"Cannot convert Chef resource '{resource_type}': {reason}"
|
|
107
|
+
suggestion = (
|
|
108
|
+
"This resource may require manual conversion. Check the Ansible "
|
|
109
|
+
"module documentation for equivalent modules, or consider using "
|
|
110
|
+
"the 'command' or 'shell' module as a fallback."
|
|
111
|
+
)
|
|
112
|
+
super().__init__(message, suggestion)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ValidationError(SousChefError):
|
|
116
|
+
"""Raised when validation of converted content fails."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, validation_type: str, issues: list[str]):
|
|
119
|
+
"""
|
|
120
|
+
Initialize validation error.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
validation_type: Type of validation that failed.
|
|
124
|
+
issues: List of validation issues found.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
issue_list = "\n - ".join(issues)
|
|
128
|
+
message = f"{validation_type} validation failed:\n - {issue_list}"
|
|
129
|
+
suggestion = (
|
|
130
|
+
"Review the validation issues above and fix them in the "
|
|
131
|
+
"generated output. Run the validation again after making "
|
|
132
|
+
"corrections."
|
|
133
|
+
)
|
|
134
|
+
super().__init__(message, suggestion)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_file_exists(path: str, file_type: str = "file") -> Path:
|
|
138
|
+
"""
|
|
139
|
+
Validate that a file exists and is readable.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Path to validate.
|
|
143
|
+
file_type: Type of file for error messages.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Path object if validation succeeds.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
FileNotFoundError: If file doesn't exist or isn't readable.
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
file_path = Path(path)
|
|
153
|
+
if not file_path.exists():
|
|
154
|
+
raise ChefFileNotFoundError(path, file_type)
|
|
155
|
+
if not file_path.is_file():
|
|
156
|
+
raise ChefFileNotFoundError(path, file_type)
|
|
157
|
+
try:
|
|
158
|
+
# Test readability
|
|
159
|
+
with file_path.open() as f:
|
|
160
|
+
f.read(1)
|
|
161
|
+
except PermissionError as e:
|
|
162
|
+
raise SousChefError(
|
|
163
|
+
f"Permission denied reading {file_type}: {path}",
|
|
164
|
+
"Ensure you have read permissions on the file. On Unix systems, "
|
|
165
|
+
"try 'chmod +r' on the file.",
|
|
166
|
+
) from e
|
|
167
|
+
return file_path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_directory_exists(path: str, dir_type: str = "directory") -> Path:
|
|
171
|
+
"""
|
|
172
|
+
Validate that a directory exists and is readable.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
path: Path to validate.
|
|
176
|
+
dir_type: Type of directory for error messages.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Path object if validation succeeds.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
FileNotFoundError: If directory doesn't exist or isn't readable.
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
dir_path = Path(path)
|
|
186
|
+
if not dir_path.exists():
|
|
187
|
+
raise ChefFileNotFoundError(path, dir_type)
|
|
188
|
+
if not dir_path.is_dir():
|
|
189
|
+
raise SousChefError(
|
|
190
|
+
f"Path is not a {dir_type}: {path}",
|
|
191
|
+
f"Expected a directory but found a file. Check that you're "
|
|
192
|
+
f"pointing to the {dir_type} directory, not a file within it.",
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
# Test readability
|
|
196
|
+
list(dir_path.iterdir())
|
|
197
|
+
except PermissionError as e:
|
|
198
|
+
raise SousChefError(
|
|
199
|
+
f"Permission denied reading {dir_type}: {path}",
|
|
200
|
+
"Ensure you have read and execute permissions on the directory. "
|
|
201
|
+
"On Unix systems, try 'chmod +rx' on the directory.",
|
|
202
|
+
) from e
|
|
203
|
+
return dir_path
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_cookbook_structure(path: str) -> Path:
|
|
207
|
+
"""
|
|
208
|
+
Validate that a path contains a valid Chef cookbook.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
path: Path to the cookbook root directory.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Path object if validation succeeds.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
InvalidCookbookError: If the directory isn't a valid cookbook.
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
cookbook_path = validate_directory_exists(path, "cookbook")
|
|
221
|
+
|
|
222
|
+
# Check for metadata.rb or metadata.json
|
|
223
|
+
has_metadata = (cookbook_path / "metadata.rb").exists() or (
|
|
224
|
+
cookbook_path / "metadata.json"
|
|
225
|
+
).exists()
|
|
226
|
+
|
|
227
|
+
if not has_metadata:
|
|
228
|
+
raise InvalidCookbookError(
|
|
229
|
+
path, "No metadata.rb or metadata.json found in cookbook root"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return cookbook_path
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_error_with_context(
|
|
236
|
+
error: Exception, operation: str, file_path: str | None = None
|
|
237
|
+
) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Format an error message with operation context.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
error: The exception that occurred.
|
|
243
|
+
operation: Description of the operation that failed.
|
|
244
|
+
file_path: Optional path to the file being processed.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Formatted error message with context and suggestions.
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
if isinstance(error, SousChefError):
|
|
251
|
+
# Already has good formatting
|
|
252
|
+
return str(error)
|
|
253
|
+
|
|
254
|
+
context = f"Error during {operation}"
|
|
255
|
+
if file_path:
|
|
256
|
+
context += f" for {file_path}"
|
|
257
|
+
|
|
258
|
+
if isinstance(error, FileNotFoundError):
|
|
259
|
+
return str(ChefFileNotFoundError(file_path or "unknown", "file"))
|
|
260
|
+
elif isinstance(error, PermissionError):
|
|
261
|
+
return (
|
|
262
|
+
f"{context}: Permission denied\n\nSuggestion: Check "
|
|
263
|
+
"file/directory permissions and ensure you have read access."
|
|
264
|
+
)
|
|
265
|
+
elif isinstance(error, (ValueError, TypeError)):
|
|
266
|
+
return (
|
|
267
|
+
f"{context}: {error}\n\nSuggestion: Check that input "
|
|
268
|
+
"values are in the correct format and type."
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
return (
|
|
272
|
+
f"{context}: {error}\n\nSuggestion: If this error persists, "
|
|
273
|
+
"please report it with the full error message at "
|
|
274
|
+
"https://github.com/kpeacocke/souschef/issues"
|
|
275
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Path utility functions for safe filesystem operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _normalize_path(path_str: str) -> Path:
|
|
7
|
+
"""
|
|
8
|
+
Normalize a file path for safe filesystem operations.
|
|
9
|
+
|
|
10
|
+
This function resolves relative paths and symlinks to absolute paths,
|
|
11
|
+
preventing path traversal attacks (CWE-23). Note: This MCP server
|
|
12
|
+
intentionally allows full filesystem access as it runs in the user's
|
|
13
|
+
local environment with their permissions.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path_str: Path string to normalize.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Resolved absolute Path object.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If the path contains null bytes or is invalid.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
if "\x00" in path_str:
|
|
26
|
+
raise ValueError(f"Path contains null bytes: {path_str!r}")
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
# Resolve to absolute path, removing .., ., and resolving symlinks
|
|
30
|
+
# This is the path normalization function itself that validates input
|
|
31
|
+
# lgtm[py/path-injection]
|
|
32
|
+
# codeql[py/path-injection]
|
|
33
|
+
return Path(path_str).resolve()
|
|
34
|
+
except (OSError, RuntimeError) as e:
|
|
35
|
+
raise ValueError(f"Invalid path {path_str}: {e}") from e
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _safe_join(base_path: Path, *parts: str) -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Safely join path components ensuring result stays within base directory.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
base_path: Normalized base path.
|
|
44
|
+
*parts: Path components to join.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Joined path within base_path.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If result would escape base_path.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
result = base_path.joinpath(*parts).resolve()
|
|
54
|
+
try:
|
|
55
|
+
result.relative_to(base_path)
|
|
56
|
+
return result
|
|
57
|
+
except ValueError as e:
|
|
58
|
+
raise ValueError(f"Path traversal attempt: {parts} escapes {base_path}") from e
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ruby value normalization utilities for Chef-to-Ansible conversion.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for normalizing Ruby values and syntax
|
|
5
|
+
during the conversion process from Chef to Ansible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_ruby_value(value: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Normalize Ruby value representation.
|
|
14
|
+
|
|
15
|
+
Converts Ruby-specific syntax to a normalized string representation
|
|
16
|
+
suitable for Ansible playbooks.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
value: Raw Ruby value string.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Normalized value string.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> _normalize_ruby_value(":symbol")
|
|
26
|
+
'"symbol"'
|
|
27
|
+
>>> _normalize_ruby_value("[:a, :b]")
|
|
28
|
+
'["a", "b"]'
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
value = value.strip()
|
|
32
|
+
# Handle symbols: :symbol -> "symbol"
|
|
33
|
+
if value.startswith(":") and value[1:].replace("_", "").isalnum():
|
|
34
|
+
return f'"{value[1:]}"'
|
|
35
|
+
# Handle arrays: [:a, :b] -> ["a", "b"]
|
|
36
|
+
if value.startswith("[") and value.endswith("]"):
|
|
37
|
+
# Simple symbol array conversion
|
|
38
|
+
value = re.sub(r":(\w+)", r'"\1"', value)
|
|
39
|
+
return value
|