mcp-souschef 2.1.2__py3-none-any.whl → 2.5.3__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.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/METADATA +200 -19
- mcp_souschef-2.5.3.dist-info/RECORD +38 -0
- mcp_souschef-2.5.3.dist-info/entry_points.txt +4 -0
- souschef/assessment.py +531 -180
- souschef/ci/__init__.py +11 -0
- souschef/ci/github_actions.py +379 -0
- souschef/ci/gitlab_ci.py +299 -0
- souschef/ci/jenkins_pipeline.py +343 -0
- souschef/cli.py +691 -1
- souschef/converters/playbook.py +43 -5
- souschef/converters/resource.py +146 -49
- souschef/core/__init__.py +22 -0
- souschef/core/errors.py +275 -0
- souschef/core/validation.py +35 -2
- souschef/deployment.py +414 -100
- souschef/filesystem/operations.py +0 -7
- souschef/parsers/__init__.py +6 -1
- souschef/parsers/habitat.py +35 -6
- souschef/parsers/inspec.py +415 -52
- souschef/parsers/metadata.py +89 -23
- souschef/profiling.py +568 -0
- souschef/server.py +948 -255
- souschef/ui/__init__.py +8 -0
- souschef/ui/app.py +1837 -0
- souschef/ui/pages/cookbook_analysis.py +425 -0
- mcp_souschef-2.1.2.dist-info/RECORD +0 -29
- mcp_souschef-2.1.2.dist-info/entry_points.txt +0 -4
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/licenses/LICENSE +0 -0
souschef/converters/playbook.py
CHANGED
|
@@ -1441,8 +1441,40 @@ def _extract_chef_guards(resource: dict[str, str], raw_content: str) -> dict[str
|
|
|
1441
1441
|
return guards
|
|
1442
1442
|
|
|
1443
1443
|
|
|
1444
|
+
def _is_opening_delimiter(char: str, in_quotes: bool) -> bool:
|
|
1445
|
+
"""Check if character is an opening delimiter."""
|
|
1446
|
+
return char == "{" and not in_quotes
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _is_closing_delimiter(char: str, in_quotes: bool) -> bool:
|
|
1450
|
+
"""Check if character is a closing delimiter."""
|
|
1451
|
+
return char == "}" and not in_quotes
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _is_quote_character(char: str) -> bool:
|
|
1455
|
+
"""Check if character is a quote."""
|
|
1456
|
+
return char in ['"', "'"]
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _should_split_here(char: str, in_quotes: bool, in_block: int) -> bool:
|
|
1460
|
+
"""Determine if we should split at this comma."""
|
|
1461
|
+
return char == "," and not in_quotes and in_block == 0
|
|
1462
|
+
|
|
1463
|
+
|
|
1444
1464
|
def _split_guard_array_parts(array_content: str) -> list[str]:
|
|
1445
|
-
"""
|
|
1465
|
+
"""
|
|
1466
|
+
Split array content by commas, respecting quotes and blocks.
|
|
1467
|
+
|
|
1468
|
+
Handles Chef guard arrays like: ['test -f /file', { block }, "string"]
|
|
1469
|
+
Tracks quote state and brace nesting to avoid splitting inside strings or blocks.
|
|
1470
|
+
|
|
1471
|
+
Args:
|
|
1472
|
+
array_content: Raw array content string
|
|
1473
|
+
|
|
1474
|
+
Returns:
|
|
1475
|
+
List of array parts split by commas
|
|
1476
|
+
|
|
1477
|
+
"""
|
|
1446
1478
|
parts = []
|
|
1447
1479
|
current_part = ""
|
|
1448
1480
|
in_quotes = False
|
|
@@ -1450,24 +1482,30 @@ def _split_guard_array_parts(array_content: str) -> list[str]:
|
|
|
1450
1482
|
quote_char = None
|
|
1451
1483
|
|
|
1452
1484
|
for char in array_content:
|
|
1453
|
-
|
|
1485
|
+
# Handle quote transitions
|
|
1486
|
+
if _is_quote_character(char) and not in_block:
|
|
1454
1487
|
if not in_quotes:
|
|
1455
1488
|
in_quotes = True
|
|
1456
1489
|
quote_char = char
|
|
1457
1490
|
elif char == quote_char:
|
|
1458
1491
|
in_quotes = False
|
|
1459
1492
|
quote_char = None
|
|
1460
|
-
|
|
1493
|
+
|
|
1494
|
+
# Handle block nesting
|
|
1495
|
+
elif _is_opening_delimiter(char, in_quotes):
|
|
1461
1496
|
in_block += 1
|
|
1462
|
-
elif char
|
|
1497
|
+
elif _is_closing_delimiter(char, in_quotes):
|
|
1463
1498
|
in_block -= 1
|
|
1464
|
-
|
|
1499
|
+
|
|
1500
|
+
# Handle splits at commas
|
|
1501
|
+
elif _should_split_here(char, in_quotes, in_block):
|
|
1465
1502
|
parts.append(current_part.strip())
|
|
1466
1503
|
current_part = ""
|
|
1467
1504
|
continue
|
|
1468
1505
|
|
|
1469
1506
|
current_part += char
|
|
1470
1507
|
|
|
1508
|
+
# Add final part if not empty
|
|
1471
1509
|
if current_part.strip():
|
|
1472
1510
|
parts.append(current_part.strip())
|
|
1473
1511
|
|
souschef/converters/resource.py
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import json
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
|
|
8
9
|
|
|
10
|
+
# Type alias for parameter builder functions
|
|
11
|
+
ParamBuilder = Callable[[str, str, dict[str, Any]], dict[str, Any]]
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
def _parse_properties(properties_str: str) -> dict[str, Any]:
|
|
11
15
|
"""
|
|
@@ -87,6 +91,39 @@ def _get_service_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
|
87
91
|
return params
|
|
88
92
|
|
|
89
93
|
|
|
94
|
+
def _get_template_file_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
95
|
+
"""Get parameters for template resources."""
|
|
96
|
+
params = {
|
|
97
|
+
"src": resource_name,
|
|
98
|
+
"dest": resource_name.replace(".erb", ""),
|
|
99
|
+
}
|
|
100
|
+
if action == "create":
|
|
101
|
+
params["mode"] = "0644"
|
|
102
|
+
return params
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_regular_file_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
106
|
+
"""Get parameters for regular file resources."""
|
|
107
|
+
params: dict[str, Any] = {"path": resource_name}
|
|
108
|
+
if action == "create":
|
|
109
|
+
params["state"] = "file"
|
|
110
|
+
params["mode"] = "0644"
|
|
111
|
+
else:
|
|
112
|
+
params["state"] = ACTION_TO_STATE.get(action, action)
|
|
113
|
+
return params
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_directory_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
117
|
+
"""Get parameters for directory resources."""
|
|
118
|
+
params: dict[str, Any] = {
|
|
119
|
+
"path": resource_name,
|
|
120
|
+
"state": "directory",
|
|
121
|
+
}
|
|
122
|
+
if action == "create":
|
|
123
|
+
params["mode"] = "0755"
|
|
124
|
+
return params
|
|
125
|
+
|
|
126
|
+
|
|
90
127
|
def _get_file_params(
|
|
91
128
|
resource_name: str, action: str, resource_type: str
|
|
92
129
|
) -> dict[str, Any]:
|
|
@@ -102,34 +139,84 @@ def _get_file_params(
|
|
|
102
139
|
Dictionary of Ansible file parameters.
|
|
103
140
|
|
|
104
141
|
"""
|
|
105
|
-
params: dict[str, Any] = {}
|
|
106
|
-
|
|
107
142
|
if resource_type == "template":
|
|
108
|
-
|
|
109
|
-
params["dest"] = resource_name.replace(".erb", "")
|
|
110
|
-
if action == "create":
|
|
111
|
-
params["mode"] = "0644"
|
|
143
|
+
return _get_template_file_params(resource_name, action)
|
|
112
144
|
elif resource_type == "file":
|
|
113
|
-
|
|
114
|
-
if action == "create":
|
|
115
|
-
params["state"] = "file"
|
|
116
|
-
params["mode"] = "0644"
|
|
117
|
-
else:
|
|
118
|
-
params["state"] = ACTION_TO_STATE.get(action, action)
|
|
145
|
+
return _get_regular_file_params(resource_name, action)
|
|
119
146
|
elif resource_type == "directory":
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
147
|
+
return _get_directory_params(resource_name, action)
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _get_package_params(
|
|
152
|
+
resource_name: str, action: str, props: dict[str, Any]
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""Build parameters for package resources."""
|
|
155
|
+
return {"name": resource_name, "state": ACTION_TO_STATE.get(action, action)}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_execute_params(
|
|
159
|
+
resource_name: str, action: str, props: dict[str, Any]
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""Build parameters for execute/bash resources."""
|
|
162
|
+
return {"cmd": resource_name}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_user_group_params(
|
|
166
|
+
resource_name: str, action: str, props: dict[str, Any]
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Build parameters for user/group resources."""
|
|
169
|
+
return {"name": resource_name, "state": ACTION_TO_STATE.get(action, "present")}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _get_remote_file_params(
|
|
173
|
+
resource_name: str, action: str, props: dict[str, Any]
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Build parameters for remote_file resources."""
|
|
176
|
+
params = {"dest": resource_name}
|
|
177
|
+
# Map Chef properties to Ansible parameters
|
|
178
|
+
prop_mappings = {
|
|
179
|
+
"source": "url",
|
|
180
|
+
"mode": "mode",
|
|
181
|
+
"owner": "owner",
|
|
182
|
+
"group": "group",
|
|
183
|
+
"checksum": "checksum",
|
|
184
|
+
}
|
|
185
|
+
for chef_prop, ansible_param in prop_mappings.items():
|
|
186
|
+
if chef_prop in props:
|
|
187
|
+
params[ansible_param] = props[chef_prop]
|
|
188
|
+
return params
|
|
124
189
|
|
|
190
|
+
|
|
191
|
+
def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
192
|
+
"""Build default parameters for unknown resource types."""
|
|
193
|
+
params = {"name": resource_name}
|
|
194
|
+
if action in ACTION_TO_STATE:
|
|
195
|
+
params["state"] = ACTION_TO_STATE[action]
|
|
125
196
|
return params
|
|
126
197
|
|
|
127
198
|
|
|
199
|
+
# Resource type to parameter builder mappings
|
|
200
|
+
RESOURCE_PARAM_BUILDERS: dict[str, ParamBuilder | str] = {
|
|
201
|
+
"package": _get_package_params,
|
|
202
|
+
"service": "service", # Uses _get_service_params
|
|
203
|
+
"systemd_unit": "service",
|
|
204
|
+
"template": "file", # Uses _get_file_params
|
|
205
|
+
"file": "file",
|
|
206
|
+
"directory": "file",
|
|
207
|
+
"execute": _get_execute_params,
|
|
208
|
+
"bash": _get_execute_params,
|
|
209
|
+
"user": _get_user_group_params,
|
|
210
|
+
"group": _get_user_group_params,
|
|
211
|
+
"remote_file": _get_remote_file_params,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
128
215
|
def _convert_chef_resource_to_ansible(
|
|
129
216
|
resource_type: str, resource_name: str, action: str, properties: str
|
|
130
217
|
) -> dict[str, Any]:
|
|
131
218
|
"""
|
|
132
|
-
Convert Chef resource to Ansible task dictionary.
|
|
219
|
+
Convert Chef resource to Ansible task dictionary using data-driven approach.
|
|
133
220
|
|
|
134
221
|
Args:
|
|
135
222
|
resource_type: The Chef resource type.
|
|
@@ -149,46 +236,56 @@ def _convert_chef_resource_to_ansible(
|
|
|
149
236
|
"name": f"{action.capitalize()} {resource_type} {resource_name}",
|
|
150
237
|
}
|
|
151
238
|
|
|
152
|
-
#
|
|
153
|
-
module_params: dict[str, Any] = {}
|
|
154
|
-
|
|
155
|
-
# Parse properties if provided
|
|
239
|
+
# Parse properties
|
|
156
240
|
props = _parse_properties(properties)
|
|
157
241
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
elif resource_type in ["template", "file", "directory"]:
|
|
164
|
-
module_params = _get_file_params(resource_name, action, resource_type)
|
|
165
|
-
elif resource_type in ["execute", "bash"]:
|
|
166
|
-
module_params["cmd"] = resource_name
|
|
242
|
+
# Build module parameters using appropriate builder
|
|
243
|
+
module_params = _build_module_params(resource_type, resource_name, action, props)
|
|
244
|
+
|
|
245
|
+
# Add special task-level flags for execute/bash resources
|
|
246
|
+
if resource_type in ["execute", "bash"]:
|
|
167
247
|
task["changed_when"] = "false"
|
|
168
|
-
elif resource_type in ["user", "group"]:
|
|
169
|
-
module_params["name"] = resource_name
|
|
170
|
-
module_params["state"] = ACTION_TO_STATE.get(action, "present")
|
|
171
|
-
elif resource_type == "remote_file":
|
|
172
|
-
module_params["dest"] = resource_name
|
|
173
|
-
if "source" in props:
|
|
174
|
-
module_params["url"] = props["source"]
|
|
175
|
-
if "mode" in props:
|
|
176
|
-
module_params["mode"] = props["mode"]
|
|
177
|
-
if "owner" in props:
|
|
178
|
-
module_params["owner"] = props["owner"]
|
|
179
|
-
if "group" in props:
|
|
180
|
-
module_params["group"] = props["group"]
|
|
181
|
-
if "checksum" in props:
|
|
182
|
-
module_params["checksum"] = props["checksum"]
|
|
183
|
-
else:
|
|
184
|
-
module_params["name"] = resource_name
|
|
185
|
-
if action in ACTION_TO_STATE:
|
|
186
|
-
module_params["state"] = ACTION_TO_STATE[action]
|
|
187
248
|
|
|
188
249
|
task[ansible_module] = module_params
|
|
189
250
|
return task
|
|
190
251
|
|
|
191
252
|
|
|
253
|
+
def _build_module_params(
|
|
254
|
+
resource_type: str, resource_name: str, action: str, props: dict[str, Any]
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
"""
|
|
257
|
+
Build Ansible module parameters based on resource type.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
resource_type: The Chef resource type.
|
|
261
|
+
resource_name: The resource name.
|
|
262
|
+
action: The Chef action.
|
|
263
|
+
props: Parsed properties dictionary.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Dictionary of Ansible module parameters.
|
|
267
|
+
|
|
268
|
+
"""
|
|
269
|
+
# Look up the parameter builder for this resource type
|
|
270
|
+
builder = RESOURCE_PARAM_BUILDERS.get(resource_type)
|
|
271
|
+
|
|
272
|
+
if builder is None:
|
|
273
|
+
# Unknown resource type - use default builder
|
|
274
|
+
return _get_default_params(resource_name, action)
|
|
275
|
+
|
|
276
|
+
if isinstance(builder, str):
|
|
277
|
+
# Special handler reference (service/file)
|
|
278
|
+
if builder == "service":
|
|
279
|
+
return _get_service_params(resource_name, action)
|
|
280
|
+
elif builder == "file":
|
|
281
|
+
return _get_file_params(resource_name, action, resource_type)
|
|
282
|
+
# This shouldn't happen, but handle gracefully
|
|
283
|
+
return _get_default_params(resource_name, action)
|
|
284
|
+
|
|
285
|
+
# Call the parameter builder function
|
|
286
|
+
return builder(resource_name, action, props)
|
|
287
|
+
|
|
288
|
+
|
|
192
289
|
def _format_yaml_value(value: Any) -> str:
|
|
193
290
|
"""Format a value for YAML output."""
|
|
194
291
|
if isinstance(value, str):
|
souschef/core/__init__.py
CHANGED
|
@@ -38,6 +38,18 @@ from souschef.core.constants import (
|
|
|
38
38
|
REGEX_WORD_SYMBOLS,
|
|
39
39
|
RESOURCE_MAPPINGS,
|
|
40
40
|
)
|
|
41
|
+
from souschef.core.errors import (
|
|
42
|
+
ChefFileNotFoundError,
|
|
43
|
+
ConversionError,
|
|
44
|
+
InvalidCookbookError,
|
|
45
|
+
ParseError,
|
|
46
|
+
SousChefError,
|
|
47
|
+
ValidationError,
|
|
48
|
+
format_error_with_context,
|
|
49
|
+
validate_cookbook_structure,
|
|
50
|
+
validate_directory_exists,
|
|
51
|
+
validate_file_exists,
|
|
52
|
+
)
|
|
41
53
|
from souschef.core.path_utils import _normalize_path, _safe_join
|
|
42
54
|
from souschef.core.ruby_utils import _normalize_ruby_value
|
|
43
55
|
from souschef.core.validation import (
|
|
@@ -55,4 +67,14 @@ __all__ = [
|
|
|
55
67
|
"ValidationEngine",
|
|
56
68
|
"ValidationLevel",
|
|
57
69
|
"ValidationResult",
|
|
70
|
+
"SousChefError",
|
|
71
|
+
"ChefFileNotFoundError",
|
|
72
|
+
"InvalidCookbookError",
|
|
73
|
+
"ParseError",
|
|
74
|
+
"ConversionError",
|
|
75
|
+
"ValidationError",
|
|
76
|
+
"validate_file_exists",
|
|
77
|
+
"validate_directory_exists",
|
|
78
|
+
"validate_cookbook_structure",
|
|
79
|
+
"format_error_with_context",
|
|
58
80
|
]
|
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
|
+
)
|
souschef/core/validation.py
CHANGED
|
@@ -234,10 +234,43 @@ class ValidationEngine:
|
|
|
234
234
|
if "import pytest" in result:
|
|
235
235
|
# Testinfra format
|
|
236
236
|
self._validate_python_syntax(result)
|
|
237
|
-
elif "
|
|
238
|
-
#
|
|
237
|
+
elif "require 'serverspec'" in result:
|
|
238
|
+
# ServerSpec format (Ruby)
|
|
239
|
+
self._validate_ruby_syntax(result)
|
|
240
|
+
elif "---" in result or ("package:" in result and "service:" in result):
|
|
241
|
+
# Ansible assert or Goss YAML format
|
|
239
242
|
self._validate_yaml_syntax(result)
|
|
240
243
|
|
|
244
|
+
def _validate_ruby_syntax(self, ruby_content: str) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Validate Ruby syntax.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
ruby_content: Ruby content to validate.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
# Basic Ruby syntax checks
|
|
253
|
+
if not ruby_content.strip():
|
|
254
|
+
self._add_result(
|
|
255
|
+
ValidationLevel.ERROR,
|
|
256
|
+
ValidationCategory.SYNTAX,
|
|
257
|
+
"Empty Ruby content",
|
|
258
|
+
suggestion="Ensure the conversion produced valid Ruby code",
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# Check for balanced blocks (describe/do/end)
|
|
263
|
+
do_count = len(re.findall(r"\bdo\b", ruby_content))
|
|
264
|
+
end_count = len(re.findall(r"\bend\b", ruby_content))
|
|
265
|
+
|
|
266
|
+
if do_count != end_count:
|
|
267
|
+
self._add_result(
|
|
268
|
+
ValidationLevel.ERROR,
|
|
269
|
+
ValidationCategory.SYNTAX,
|
|
270
|
+
f"Unbalanced Ruby blocks: {do_count} 'do' but {end_count} 'end'",
|
|
271
|
+
suggestion="Check that all 'do' blocks have matching 'end' keywords",
|
|
272
|
+
)
|
|
273
|
+
|
|
241
274
|
def _validate_yaml_syntax(self, yaml_content: str) -> None:
|
|
242
275
|
"""
|
|
243
276
|
Validate YAML syntax.
|