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.
@@ -0,0 +1,325 @@
1
+ """Chef resource to Ansible task converter."""
2
+
3
+ import ast
4
+ import json
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
9
+
10
+ # Type alias for parameter builder functions
11
+ ParamBuilder = Callable[[str, str, dict[str, Any]], dict[str, Any]]
12
+
13
+
14
+ def _parse_properties(properties_str: str) -> dict[str, Any]:
15
+ """
16
+ Parse properties string into a dictionary.
17
+
18
+ Args:
19
+ properties_str: String representation of properties dict.
20
+
21
+ Returns:
22
+ Dictionary of properties.
23
+
24
+ """
25
+ if not properties_str:
26
+ return {}
27
+ try:
28
+ # Try ast.literal_eval first for safety
29
+ result = ast.literal_eval(properties_str)
30
+ if isinstance(result, dict):
31
+ return result
32
+ return {}
33
+ except (ValueError, SyntaxError):
34
+ # Fallback to eval if needed, but this is less safe
35
+ try:
36
+ result = eval(properties_str) # noqa: S307
37
+ if isinstance(result, dict):
38
+ return result
39
+ return {}
40
+ except Exception:
41
+ return {}
42
+
43
+
44
+ def convert_resource_to_task(
45
+ resource_type: str, resource_name: str, action: str = "create", properties: str = ""
46
+ ) -> str:
47
+ """
48
+ Convert a Chef resource to an Ansible task.
49
+
50
+ Args:
51
+ resource_type: The Chef resource type (e.g., 'package', 'service').
52
+ resource_name: The name of the resource.
53
+ action: The Chef action (e.g., 'install', 'start', 'create').
54
+ Defaults to 'create'.
55
+ properties: Additional resource properties as a string representation.
56
+
57
+ Returns:
58
+ YAML representation of the equivalent Ansible task.
59
+
60
+ """
61
+ try:
62
+ task = _convert_chef_resource_to_ansible(
63
+ resource_type, resource_name, action, properties
64
+ )
65
+ return _format_ansible_task(task)
66
+ except Exception as e:
67
+ return f"An error occurred during conversion: {e}"
68
+
69
+
70
+ def _get_service_params(resource_name: str, action: str) -> dict[str, Any]:
71
+ """
72
+ Get Ansible service module parameters.
73
+
74
+ Args:
75
+ resource_name: Service name.
76
+ action: Chef action.
77
+
78
+ Returns:
79
+ Dictionary of Ansible service parameters.
80
+
81
+ """
82
+ params: dict[str, Any] = {"name": resource_name}
83
+ if action in ["enable", "start"]:
84
+ params["enabled"] = True
85
+ params["state"] = "started"
86
+ elif action in ["disable", "stop"]:
87
+ params["enabled"] = False
88
+ params["state"] = "stopped"
89
+ else:
90
+ params["state"] = ACTION_TO_STATE.get(action, action)
91
+ return params
92
+
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
+
127
+ def _get_file_params(
128
+ resource_name: str, action: str, resource_type: str
129
+ ) -> dict[str, Any]:
130
+ """
131
+ Get Ansible file module parameters.
132
+
133
+ Args:
134
+ resource_name: File/directory path.
135
+ action: Chef action.
136
+ resource_type: Type of file resource (file/directory/template).
137
+
138
+ Returns:
139
+ Dictionary of Ansible file parameters.
140
+
141
+ """
142
+ if resource_type == "template":
143
+ return _get_template_file_params(resource_name, action)
144
+ elif resource_type == "file":
145
+ return _get_regular_file_params(resource_name, action)
146
+ elif resource_type == "directory":
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
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]
196
+ return params
197
+
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
+
215
+ def _convert_chef_resource_to_ansible(
216
+ resource_type: str, resource_name: str, action: str, properties: str
217
+ ) -> dict[str, Any]:
218
+ """
219
+ Convert Chef resource to Ansible task dictionary using data-driven approach.
220
+
221
+ Args:
222
+ resource_type: The Chef resource type.
223
+ resource_name: The name of the resource.
224
+ action: The Chef action.
225
+ properties: Additional properties string.
226
+
227
+ Returns:
228
+ Dictionary representing an Ansible task.
229
+
230
+ """
231
+ # Get Ansible module name
232
+ ansible_module = RESOURCE_MAPPINGS.get(resource_type, f"# Unknown: {resource_type}")
233
+
234
+ # Start building the task
235
+ task: dict[str, Any] = {
236
+ "name": f"{action.capitalize()} {resource_type} {resource_name}",
237
+ }
238
+
239
+ # Parse properties
240
+ props = _parse_properties(properties)
241
+
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"]:
247
+ task["changed_when"] = "false"
248
+
249
+ task[ansible_module] = module_params
250
+ return task
251
+
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
+
289
+ def _format_yaml_value(value: Any) -> str:
290
+ """Format a value for YAML output."""
291
+ if isinstance(value, str):
292
+ return f'"{value}"'
293
+ return json.dumps(value)
294
+
295
+
296
+ def _format_dict_value(key: str, value: dict[str, Any]) -> list[str]:
297
+ """Format a dictionary value for YAML output."""
298
+ lines = [f" {key}:"]
299
+ for param_key, param_value in value.items():
300
+ lines.append(f" {param_key}: {_format_yaml_value(param_value)}")
301
+ return lines
302
+
303
+
304
+ def _format_ansible_task(task: dict[str, Any]) -> str:
305
+ """
306
+ Format an Ansible task dictionary as YAML.
307
+
308
+ Args:
309
+ task: Dictionary representing an Ansible task.
310
+
311
+ Returns:
312
+ YAML-formatted string.
313
+
314
+ """
315
+ result = ["- name: " + task["name"]]
316
+
317
+ for key, value in task.items():
318
+ if key == "name":
319
+ continue
320
+ if isinstance(value, dict):
321
+ result.extend(_format_dict_value(key, value))
322
+ else:
323
+ result.append(f" {key}: {_format_yaml_value(value)}")
324
+
325
+ return "\n".join(result)
@@ -0,0 +1,80 @@
1
+ """Core utilities for SousChef."""
2
+
3
+ from souschef.core.constants import (
4
+ ACTION_TO_STATE,
5
+ ANSIBLE_SERVICE_MODULE,
6
+ CHEF_RECIPE_PREFIX,
7
+ CHEF_ROLE_PREFIX,
8
+ ERB_PATTERNS,
9
+ ERROR_FILE_NOT_FOUND,
10
+ ERROR_IS_DIRECTORY,
11
+ ERROR_PERMISSION_DENIED,
12
+ ERROR_PREFIX,
13
+ INSPEC_END_INDENT,
14
+ INSPEC_SHOULD_EXIST,
15
+ JINJA2_ELIF,
16
+ JINJA2_ELSE,
17
+ JINJA2_ENDIF,
18
+ JINJA2_FOR,
19
+ JINJA2_IF_NOT,
20
+ JINJA2_IF_START,
21
+ JINJA2_NODE_ATTR_REPLACEMENT,
22
+ JINJA2_VAR_REPLACEMENT,
23
+ METADATA_FILENAME,
24
+ NODE_PREFIX,
25
+ REGEX_ERB_CONDITION,
26
+ REGEX_ERB_EACH,
27
+ REGEX_ERB_ELSE,
28
+ REGEX_ERB_ELSIF,
29
+ REGEX_ERB_END,
30
+ REGEX_ERB_IF_START,
31
+ REGEX_ERB_NODE_ATTR,
32
+ REGEX_ERB_OUTPUT,
33
+ REGEX_ERB_UNLESS,
34
+ REGEX_QUOTE_DO_END,
35
+ REGEX_RESOURCE_BRACKET,
36
+ REGEX_RUBY_INTERPOLATION,
37
+ REGEX_WHITESPACE_QUOTE,
38
+ REGEX_WORD_SYMBOLS,
39
+ RESOURCE_MAPPINGS,
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
+ )
53
+ from souschef.core.path_utils import _normalize_path, _safe_join
54
+ from souschef.core.ruby_utils import _normalize_ruby_value
55
+ from souschef.core.validation import (
56
+ ValidationCategory,
57
+ ValidationEngine,
58
+ ValidationLevel,
59
+ ValidationResult,
60
+ )
61
+
62
+ __all__ = [
63
+ "_normalize_path",
64
+ "_normalize_ruby_value",
65
+ "_safe_join",
66
+ "ValidationCategory",
67
+ "ValidationEngine",
68
+ "ValidationLevel",
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",
80
+ ]
@@ -0,0 +1,145 @@
1
+ """Constants used throughout SousChef."""
2
+
3
+ __all__ = [
4
+ "ANSIBLE_SERVICE_MODULE",
5
+ "METADATA_FILENAME",
6
+ "ERROR_PREFIX",
7
+ "NODE_PREFIX",
8
+ "CHEF_RECIPE_PREFIX",
9
+ "CHEF_ROLE_PREFIX",
10
+ "REGEX_WHITESPACE_QUOTE",
11
+ "REGEX_QUOTE_DO_END",
12
+ "REGEX_RESOURCE_BRACKET",
13
+ "REGEX_ERB_OUTPUT",
14
+ "REGEX_ERB_CONDITION",
15
+ "REGEX_ERB_NODE_ATTR",
16
+ "REGEX_ERB_IF_START",
17
+ "REGEX_ERB_UNLESS",
18
+ "REGEX_ERB_ELSE",
19
+ "REGEX_ERB_ELSIF",
20
+ "REGEX_ERB_END",
21
+ "REGEX_ERB_EACH",
22
+ "REGEX_WORD_SYMBOLS",
23
+ "REGEX_RUBY_INTERPOLATION",
24
+ "JINJA2_VAR_REPLACEMENT",
25
+ "JINJA2_NODE_ATTR_REPLACEMENT",
26
+ "JINJA2_IF_START",
27
+ "JINJA2_IF_NOT",
28
+ "JINJA2_ELSE",
29
+ "JINJA2_ELIF",
30
+ "JINJA2_ENDIF",
31
+ "JINJA2_FOR",
32
+ "ERB_PATTERNS",
33
+ "INSPEC_END_INDENT",
34
+ "INSPEC_SHOULD_EXIST",
35
+ "ERROR_FILE_NOT_FOUND",
36
+ "ERROR_IS_DIRECTORY",
37
+ "ERROR_PERMISSION_DENIED",
38
+ "RESOURCE_MAPPINGS",
39
+ "ACTION_TO_STATE",
40
+ ]
41
+
42
+ # Ansible module names
43
+ ANSIBLE_SERVICE_MODULE = "ansible.builtin.service"
44
+
45
+ # File names
46
+ METADATA_FILENAME = "metadata.rb"
47
+
48
+ # Common prefixes
49
+ ERROR_PREFIX = "Error:"
50
+ NODE_PREFIX = "node["
51
+ CHEF_RECIPE_PREFIX = "recipe["
52
+ CHEF_ROLE_PREFIX = "role["
53
+
54
+ # Regular expression patterns for Ruby/ERB parsing
55
+ REGEX_WHITESPACE_QUOTE = r"\s+['\"]?"
56
+ REGEX_QUOTE_DO_END = r"['\"]?\s+do\s*([^\n]{0,15000})\nend"
57
+ REGEX_RESOURCE_BRACKET = r"(\w+)\[([^\]]+)\]"
58
+ REGEX_ERB_OUTPUT = r"<%=\s*([^%]{1,200}?)\s*%>"
59
+ REGEX_ERB_CONDITION = r"[^%]{1,200}?"
60
+ REGEX_ERB_NODE_ATTR = rf"<%=\s*node\[(['\"])({REGEX_ERB_CONDITION})\1\]\s*%>"
61
+ REGEX_ERB_IF_START = rf"<%\s*if\s+({REGEX_ERB_CONDITION})\s*%>"
62
+ REGEX_ERB_UNLESS = rf"<%\s*unless\s+({REGEX_ERB_CONDITION})\s*%>"
63
+ REGEX_ERB_ELSE = r"<%\s*else\s*%>"
64
+ REGEX_ERB_ELSIF = rf"<%\s*elsif\s+({REGEX_ERB_CONDITION})\s*%>"
65
+ REGEX_ERB_END = r"<%\s*end\s*%>"
66
+ REGEX_ERB_EACH = rf"<%\s*({REGEX_ERB_CONDITION})\.each\s+do\s+\|(\w+)\|\s*%>"
67
+ REGEX_WORD_SYMBOLS = r"[\w.\[\]'\"]+"
68
+ REGEX_RUBY_INTERPOLATION = r"#\{([^}]+)\}"
69
+
70
+ # Jinja2 template replacements
71
+ JINJA2_VAR_REPLACEMENT = r"{{ \1 }}"
72
+ JINJA2_NODE_ATTR_REPLACEMENT = r"{{ \2 }}"
73
+ JINJA2_IF_START = r"{% if \1 %}"
74
+ JINJA2_IF_NOT = r"{% if not \1 %}"
75
+ JINJA2_ELSE = r"{% else %}"
76
+ JINJA2_ELIF = r"{% elif \1 %}"
77
+ JINJA2_ENDIF = r"{% endif %}"
78
+ JINJA2_FOR = r"{% for \2 in \1 %}"
79
+
80
+ # ERB to Jinja2 pattern mappings
81
+ ERB_PATTERNS = {
82
+ # Variable output: <%= var %> -> {{ var }}
83
+ "output": (REGEX_ERB_OUTPUT, JINJA2_VAR_REPLACEMENT),
84
+ # Variable with node prefix: <%= node['attr'] %> -> {{ attr }}
85
+ "node_attr": (REGEX_ERB_NODE_ATTR, JINJA2_NODE_ATTR_REPLACEMENT),
86
+ # If statements: <% if condition %> -> {% if condition %}
87
+ "if_start": (REGEX_ERB_IF_START, JINJA2_IF_START),
88
+ # Unless (negated if): <% unless condition %> -> {% if not condition %}
89
+ "unless": (REGEX_ERB_UNLESS, JINJA2_IF_NOT),
90
+ # Else: <% else %> -> {% else %}
91
+ "else": (REGEX_ERB_ELSE, JINJA2_ELSE),
92
+ # Elsif: <% elsif condition %> -> {% elif condition %}
93
+ "elsif": (REGEX_ERB_ELSIF, JINJA2_ELIF),
94
+ # End: <% end %> -> {% endif %}
95
+ "end": (REGEX_ERB_END, JINJA2_ENDIF),
96
+ # Each loop: <% array.each do |item| %> -> {% for item in array %}
97
+ # Use [^%]{1,200} to prevent matching across %> boundaries and ReDoS
98
+ "each": (REGEX_ERB_EACH, JINJA2_FOR),
99
+ }
100
+
101
+ # InSpec output formatting
102
+ INSPEC_END_INDENT = " end"
103
+ INSPEC_SHOULD_EXIST = " it { should exist }"
104
+
105
+ # Error message templates
106
+ ERROR_FILE_NOT_FOUND = "Error: File not found at {path}"
107
+ ERROR_IS_DIRECTORY = "Error: {path} is a directory, not a file"
108
+ ERROR_PERMISSION_DENIED = "Error: Permission denied for {path}"
109
+
110
+ # Chef resource to Ansible module mappings
111
+ RESOURCE_MAPPINGS = {
112
+ "package": "ansible.builtin.package",
113
+ "apt_package": "ansible.builtin.apt",
114
+ "yum_package": "ansible.builtin.yum",
115
+ "service": ANSIBLE_SERVICE_MODULE,
116
+ "systemd_unit": "ansible.builtin.systemd",
117
+ "template": "ansible.builtin.template",
118
+ "file": "ansible.builtin.file",
119
+ "directory": "ansible.builtin.file",
120
+ "execute": "ansible.builtin.command",
121
+ "bash": "ansible.builtin.shell",
122
+ "script": "ansible.builtin.script",
123
+ "user": "ansible.builtin.user",
124
+ "group": "ansible.builtin.group",
125
+ "cron": "ansible.builtin.cron",
126
+ "mount": "ansible.builtin.mount",
127
+ "git": "ansible.builtin.git",
128
+ "remote_file": "ansible.builtin.get_url",
129
+ }
130
+
131
+ # Chef action to Ansible state mappings
132
+ ACTION_TO_STATE = {
133
+ "create": "present",
134
+ "delete": "absent",
135
+ "remove": "absent",
136
+ "install": "present",
137
+ "upgrade": "latest",
138
+ "purge": "absent",
139
+ "enable": "started",
140
+ "disable": "stopped",
141
+ "start": "started",
142
+ "stop": "stopped",
143
+ "restart": "restarted",
144
+ "reload": "reloaded",
145
+ }