mcp-souschef 2.0.1__py3-none-any.whl → 2.1.2__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,228 @@
1
+ """Chef resource to Ansible task converter."""
2
+
3
+ import ast
4
+ import json
5
+ from typing import Any
6
+
7
+ from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
8
+
9
+
10
+ def _parse_properties(properties_str: str) -> dict[str, Any]:
11
+ """
12
+ Parse properties string into a dictionary.
13
+
14
+ Args:
15
+ properties_str: String representation of properties dict.
16
+
17
+ Returns:
18
+ Dictionary of properties.
19
+
20
+ """
21
+ if not properties_str:
22
+ return {}
23
+ try:
24
+ # Try ast.literal_eval first for safety
25
+ result = ast.literal_eval(properties_str)
26
+ if isinstance(result, dict):
27
+ return result
28
+ return {}
29
+ except (ValueError, SyntaxError):
30
+ # Fallback to eval if needed, but this is less safe
31
+ try:
32
+ result = eval(properties_str) # noqa: S307
33
+ if isinstance(result, dict):
34
+ return result
35
+ return {}
36
+ except Exception:
37
+ return {}
38
+
39
+
40
+ def convert_resource_to_task(
41
+ resource_type: str, resource_name: str, action: str = "create", properties: str = ""
42
+ ) -> str:
43
+ """
44
+ Convert a Chef resource to an Ansible task.
45
+
46
+ Args:
47
+ resource_type: The Chef resource type (e.g., 'package', 'service').
48
+ resource_name: The name of the resource.
49
+ action: The Chef action (e.g., 'install', 'start', 'create').
50
+ Defaults to 'create'.
51
+ properties: Additional resource properties as a string representation.
52
+
53
+ Returns:
54
+ YAML representation of the equivalent Ansible task.
55
+
56
+ """
57
+ try:
58
+ task = _convert_chef_resource_to_ansible(
59
+ resource_type, resource_name, action, properties
60
+ )
61
+ return _format_ansible_task(task)
62
+ except Exception as e:
63
+ return f"An error occurred during conversion: {e}"
64
+
65
+
66
+ def _get_service_params(resource_name: str, action: str) -> dict[str, Any]:
67
+ """
68
+ Get Ansible service module parameters.
69
+
70
+ Args:
71
+ resource_name: Service name.
72
+ action: Chef action.
73
+
74
+ Returns:
75
+ Dictionary of Ansible service parameters.
76
+
77
+ """
78
+ params: dict[str, Any] = {"name": resource_name}
79
+ if action in ["enable", "start"]:
80
+ params["enabled"] = True
81
+ params["state"] = "started"
82
+ elif action in ["disable", "stop"]:
83
+ params["enabled"] = False
84
+ params["state"] = "stopped"
85
+ else:
86
+ params["state"] = ACTION_TO_STATE.get(action, action)
87
+ return params
88
+
89
+
90
+ def _get_file_params(
91
+ resource_name: str, action: str, resource_type: str
92
+ ) -> dict[str, Any]:
93
+ """
94
+ Get Ansible file module parameters.
95
+
96
+ Args:
97
+ resource_name: File/directory path.
98
+ action: Chef action.
99
+ resource_type: Type of file resource (file/directory/template).
100
+
101
+ Returns:
102
+ Dictionary of Ansible file parameters.
103
+
104
+ """
105
+ params: dict[str, Any] = {}
106
+
107
+ if resource_type == "template":
108
+ params["src"] = resource_name
109
+ params["dest"] = resource_name.replace(".erb", "")
110
+ if action == "create":
111
+ params["mode"] = "0644"
112
+ elif resource_type == "file":
113
+ params["path"] = resource_name
114
+ if action == "create":
115
+ params["state"] = "file"
116
+ params["mode"] = "0644"
117
+ else:
118
+ params["state"] = ACTION_TO_STATE.get(action, action)
119
+ elif resource_type == "directory":
120
+ params["path"] = resource_name
121
+ params["state"] = "directory"
122
+ if action == "create":
123
+ params["mode"] = "0755"
124
+
125
+ return params
126
+
127
+
128
+ def _convert_chef_resource_to_ansible(
129
+ resource_type: str, resource_name: str, action: str, properties: str
130
+ ) -> dict[str, Any]:
131
+ """
132
+ Convert Chef resource to Ansible task dictionary.
133
+
134
+ Args:
135
+ resource_type: The Chef resource type.
136
+ resource_name: The name of the resource.
137
+ action: The Chef action.
138
+ properties: Additional properties string.
139
+
140
+ Returns:
141
+ Dictionary representing an Ansible task.
142
+
143
+ """
144
+ # Get Ansible module name
145
+ ansible_module = RESOURCE_MAPPINGS.get(resource_type, f"# Unknown: {resource_type}")
146
+
147
+ # Start building the task
148
+ task: dict[str, Any] = {
149
+ "name": f"{action.capitalize()} {resource_type} {resource_name}",
150
+ }
151
+
152
+ # Build module parameters based on resource type
153
+ module_params: dict[str, Any] = {}
154
+
155
+ # Parse properties if provided
156
+ props = _parse_properties(properties)
157
+
158
+ if resource_type == "package":
159
+ module_params["name"] = resource_name
160
+ module_params["state"] = ACTION_TO_STATE.get(action, action)
161
+ elif resource_type in ["service", "systemd_unit"]:
162
+ module_params = _get_service_params(resource_name, action)
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
167
+ 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
+
188
+ task[ansible_module] = module_params
189
+ return task
190
+
191
+
192
+ def _format_yaml_value(value: Any) -> str:
193
+ """Format a value for YAML output."""
194
+ if isinstance(value, str):
195
+ return f'"{value}"'
196
+ return json.dumps(value)
197
+
198
+
199
+ def _format_dict_value(key: str, value: dict[str, Any]) -> list[str]:
200
+ """Format a dictionary value for YAML output."""
201
+ lines = [f" {key}:"]
202
+ for param_key, param_value in value.items():
203
+ lines.append(f" {param_key}: {_format_yaml_value(param_value)}")
204
+ return lines
205
+
206
+
207
+ def _format_ansible_task(task: dict[str, Any]) -> str:
208
+ """
209
+ Format an Ansible task dictionary as YAML.
210
+
211
+ Args:
212
+ task: Dictionary representing an Ansible task.
213
+
214
+ Returns:
215
+ YAML-formatted string.
216
+
217
+ """
218
+ result = ["- name: " + task["name"]]
219
+
220
+ for key, value in task.items():
221
+ if key == "name":
222
+ continue
223
+ if isinstance(value, dict):
224
+ result.extend(_format_dict_value(key, value))
225
+ else:
226
+ result.append(f" {key}: {_format_yaml_value(value)}")
227
+
228
+ return "\n".join(result)
@@ -0,0 +1,58 @@
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.path_utils import _normalize_path, _safe_join
42
+ from souschef.core.ruby_utils import _normalize_ruby_value
43
+ from souschef.core.validation import (
44
+ ValidationCategory,
45
+ ValidationEngine,
46
+ ValidationLevel,
47
+ ValidationResult,
48
+ )
49
+
50
+ __all__ = [
51
+ "_normalize_path",
52
+ "_normalize_ruby_value",
53
+ "_safe_join",
54
+ "ValidationCategory",
55
+ "ValidationEngine",
56
+ "ValidationLevel",
57
+ "ValidationResult",
58
+ ]
@@ -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
+ }
@@ -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