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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Filesystem operations for Chef cookbook exploration."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from souschef.core.constants import (
|
|
6
|
+
ERROR_FILE_NOT_FOUND,
|
|
7
|
+
ERROR_IS_DIRECTORY,
|
|
8
|
+
ERROR_PERMISSION_DENIED,
|
|
9
|
+
)
|
|
10
|
+
from souschef.core.path_utils import _normalize_path
|
|
11
|
+
|
|
12
|
+
# Get MCP instance to register tools
|
|
13
|
+
mcp = FastMCP("souschef")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@mcp.tool()
|
|
17
|
+
def list_directory(path: str) -> list[str] | str:
|
|
18
|
+
"""
|
|
19
|
+
List the contents of a directory.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: The path to the directory to list.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A list of filenames in the directory, or an error message.
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
dir_path = _normalize_path(path)
|
|
30
|
+
return [item.name for item in dir_path.iterdir()]
|
|
31
|
+
except ValueError as e:
|
|
32
|
+
return f"Error: {e}"
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
return f"Error: Directory not found at {path}"
|
|
35
|
+
except NotADirectoryError:
|
|
36
|
+
return f"Error: {path} is not a directory"
|
|
37
|
+
except PermissionError:
|
|
38
|
+
return ERROR_PERMISSION_DENIED.format(path=path)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return f"An error occurred: {e}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def read_file(path: str) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Read the contents of a file.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: The path to the file to read.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The contents of the file, or an error message.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
file_path = _normalize_path(path)
|
|
57
|
+
return file_path.read_text(encoding="utf-8")
|
|
58
|
+
except ValueError as e:
|
|
59
|
+
return f"Error: {e}"
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return ERROR_FILE_NOT_FOUND.format(path=path)
|
|
62
|
+
except IsADirectoryError:
|
|
63
|
+
return ERROR_IS_DIRECTORY.format(path=path)
|
|
64
|
+
except PermissionError:
|
|
65
|
+
return ERROR_PERMISSION_DENIED.format(path=path)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return f"An error occurred: {e}"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Chef cookbook parsers."""
|
|
2
|
+
|
|
3
|
+
from souschef.core.validation import (
|
|
4
|
+
ValidationCategory,
|
|
5
|
+
ValidationEngine,
|
|
6
|
+
ValidationLevel,
|
|
7
|
+
ValidationResult,
|
|
8
|
+
)
|
|
9
|
+
from souschef.parsers.attributes import parse_attributes
|
|
10
|
+
from souschef.parsers.habitat import parse_habitat_plan
|
|
11
|
+
from souschef.parsers.inspec import (
|
|
12
|
+
convert_inspec_to_test,
|
|
13
|
+
generate_inspec_from_chef,
|
|
14
|
+
parse_inspec_profile,
|
|
15
|
+
)
|
|
16
|
+
from souschef.parsers.metadata import list_cookbook_structure, read_cookbook_metadata
|
|
17
|
+
from souschef.parsers.recipe import parse_recipe
|
|
18
|
+
from souschef.parsers.resource import parse_custom_resource
|
|
19
|
+
from souschef.parsers.template import parse_template
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"parse_template",
|
|
23
|
+
"parse_recipe",
|
|
24
|
+
"parse_attributes",
|
|
25
|
+
"parse_custom_resource",
|
|
26
|
+
"read_cookbook_metadata",
|
|
27
|
+
"list_cookbook_structure",
|
|
28
|
+
"parse_inspec_profile",
|
|
29
|
+
"convert_inspec_to_test",
|
|
30
|
+
"generate_inspec_from_chef",
|
|
31
|
+
"parse_habitat_plan",
|
|
32
|
+
"ValidationCategory",
|
|
33
|
+
"ValidationEngine",
|
|
34
|
+
"ValidationLevel",
|
|
35
|
+
"ValidationResult",
|
|
36
|
+
]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Chef attributes file parser."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from souschef.core.constants import (
|
|
7
|
+
ERROR_FILE_NOT_FOUND,
|
|
8
|
+
ERROR_IS_DIRECTORY,
|
|
9
|
+
ERROR_PERMISSION_DENIED,
|
|
10
|
+
)
|
|
11
|
+
from souschef.core.path_utils import _normalize_path
|
|
12
|
+
from souschef.parsers.template import _strip_ruby_comments
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_attributes(path: str, resolve_precedence: bool = True) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Parse a Chef attributes file and extract attribute definitions.
|
|
18
|
+
|
|
19
|
+
Analyzes attributes file and extracts all attribute definitions with their
|
|
20
|
+
precedence levels and values. By default, resolves precedence conflicts
|
|
21
|
+
to show the winning value for each attribute path.
|
|
22
|
+
|
|
23
|
+
Chef attribute precedence (lowest to highest):
|
|
24
|
+
1. default - Normal default value
|
|
25
|
+
2. force_default - Forced default, higher than regular default
|
|
26
|
+
3. normal - Normal attribute set by cookbook
|
|
27
|
+
4. override - Override values
|
|
28
|
+
5. force_override - Forced override, cannot be overridden
|
|
29
|
+
6. automatic - Automatically detected by Ohai (highest precedence)
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Path to the attributes (.rb) file.
|
|
33
|
+
resolve_precedence: If True (default), resolves precedence conflicts
|
|
34
|
+
and shows only winning values. If False, shows all attributes.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Formatted string with extracted attributes.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
file_path = _normalize_path(path)
|
|
42
|
+
content = file_path.read_text(encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
attributes = _extract_attributes(content)
|
|
45
|
+
|
|
46
|
+
if not attributes:
|
|
47
|
+
return f"Warning: No attributes found in {path}"
|
|
48
|
+
|
|
49
|
+
if resolve_precedence:
|
|
50
|
+
resolved = _resolve_attribute_precedence(attributes)
|
|
51
|
+
return _format_resolved_attributes(resolved)
|
|
52
|
+
else:
|
|
53
|
+
return _format_attributes(attributes)
|
|
54
|
+
|
|
55
|
+
except ValueError as e:
|
|
56
|
+
return f"Error: {e}"
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
return ERROR_FILE_NOT_FOUND.format(path=path)
|
|
59
|
+
except IsADirectoryError:
|
|
60
|
+
return ERROR_IS_DIRECTORY.format(path=path)
|
|
61
|
+
except PermissionError:
|
|
62
|
+
return ERROR_PERMISSION_DENIED.format(path=path)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return f"An error occurred: {e}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extract_attributes(content: str) -> list[dict[str, str]]:
|
|
68
|
+
"""
|
|
69
|
+
Extract Chef attributes from attributes file content.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
content: Raw content of attributes file.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of dictionaries containing attribute information.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
attributes = []
|
|
79
|
+
# Strip comments first
|
|
80
|
+
clean_content = _strip_ruby_comments(content)
|
|
81
|
+
|
|
82
|
+
# Match attribute declarations with all precedence levels
|
|
83
|
+
# Chef precedence levels (lowest to highest):
|
|
84
|
+
# default < force_default < normal < override < force_override < automatic
|
|
85
|
+
pattern = (
|
|
86
|
+
r"(default|force_default|normal|override|force_override|automatic)"
|
|
87
|
+
r"((?:\[[^\]]+\])+)\s*=\s*([^\n]+)"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for match in re.finditer(pattern, clean_content, re.DOTALL):
|
|
91
|
+
precedence = match.group(1)
|
|
92
|
+
# Extract the bracket part and clean it up
|
|
93
|
+
brackets = match.group(2)
|
|
94
|
+
# Clean up the path - remove quotes and brackets, convert to dot notation
|
|
95
|
+
attr_path = (
|
|
96
|
+
brackets.replace("']['", ".")
|
|
97
|
+
.replace('"]["', ".")
|
|
98
|
+
.replace("['", "")
|
|
99
|
+
.replace("']", "")
|
|
100
|
+
.replace('["', "")
|
|
101
|
+
.replace('"]', "")
|
|
102
|
+
)
|
|
103
|
+
value = match.group(3).strip()
|
|
104
|
+
|
|
105
|
+
attributes.append(
|
|
106
|
+
{
|
|
107
|
+
"precedence": precedence,
|
|
108
|
+
"path": attr_path,
|
|
109
|
+
"value": value,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return attributes
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_precedence_level(precedence: str) -> int:
|
|
117
|
+
"""
|
|
118
|
+
Get numeric precedence level for Chef attribute precedence.
|
|
119
|
+
|
|
120
|
+
Chef attribute precedence (lowest to highest):
|
|
121
|
+
1. default
|
|
122
|
+
2. force_default
|
|
123
|
+
3. normal
|
|
124
|
+
4. override
|
|
125
|
+
5. force_override
|
|
126
|
+
6. automatic
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
precedence: Chef attribute precedence level.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Numeric precedence level (1-6).
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
precedence_map = {
|
|
136
|
+
"default": 1,
|
|
137
|
+
"force_default": 2,
|
|
138
|
+
"normal": 3,
|
|
139
|
+
"override": 4,
|
|
140
|
+
"force_override": 5,
|
|
141
|
+
"automatic": 6,
|
|
142
|
+
}
|
|
143
|
+
return precedence_map.get(precedence, 1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_attribute_precedence(
|
|
147
|
+
attributes: list[dict[str, str]],
|
|
148
|
+
) -> dict[str, dict[str, str | bool | int]]:
|
|
149
|
+
"""
|
|
150
|
+
Resolve attribute precedence conflicts based on Chef's precedence rules.
|
|
151
|
+
|
|
152
|
+
When multiple attributes with the same path exist, the one with
|
|
153
|
+
higher precedence wins. Returns the winning value for each path
|
|
154
|
+
along with precedence information.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
attributes: List of attribute dictionaries with precedence, path, and value.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary mapping attribute paths to their resolved values and metadata.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
# Group attributes by path
|
|
164
|
+
path_groups: dict[str, list[dict[str, str]]] = {}
|
|
165
|
+
for attr in attributes:
|
|
166
|
+
path = attr["path"]
|
|
167
|
+
if path not in path_groups:
|
|
168
|
+
path_groups[path] = []
|
|
169
|
+
path_groups[path].append(attr)
|
|
170
|
+
|
|
171
|
+
# Resolve precedence for each path
|
|
172
|
+
resolved: dict[str, dict[str, Any]] = {}
|
|
173
|
+
for path, attrs in path_groups.items():
|
|
174
|
+
# Find attribute with highest precedence
|
|
175
|
+
winning_attr = max(attrs, key=lambda a: _get_precedence_level(a["precedence"]))
|
|
176
|
+
|
|
177
|
+
# Check for conflicts (multiple values at different precedence levels)
|
|
178
|
+
has_conflict = len(attrs) > 1
|
|
179
|
+
conflict_info = []
|
|
180
|
+
if has_conflict:
|
|
181
|
+
# Sort by precedence for conflict reporting
|
|
182
|
+
sorted_attrs = sorted(
|
|
183
|
+
attrs, key=lambda a: _get_precedence_level(a["precedence"])
|
|
184
|
+
)
|
|
185
|
+
conflict_info = [
|
|
186
|
+
f"{a['precedence']}={a['value']}" for a in sorted_attrs[:-1]
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
resolved[path] = {
|
|
190
|
+
"value": winning_attr["value"],
|
|
191
|
+
"precedence": winning_attr["precedence"],
|
|
192
|
+
"precedence_level": _get_precedence_level(winning_attr["precedence"]),
|
|
193
|
+
"has_conflict": has_conflict,
|
|
194
|
+
"overridden_values": ", ".join(conflict_info) if conflict_info else "",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return resolved
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _format_attributes(attributes: list[dict[str, str]]) -> str:
|
|
201
|
+
"""
|
|
202
|
+
Format attributes list as a readable string.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
attributes: List of attribute dictionaries.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Formatted string representation.
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
result = []
|
|
212
|
+
for attr in attributes:
|
|
213
|
+
result.append(f"{attr['precedence']}[{attr['path']}] = {attr['value']}")
|
|
214
|
+
|
|
215
|
+
return "\n".join(result)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _format_resolved_attributes(
|
|
219
|
+
resolved: dict[str, dict[str, str | bool | int]],
|
|
220
|
+
) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Format resolved attributes with precedence information.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
resolved: Dictionary mapping attribute paths to resolved values and metadata.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Formatted string showing resolved attributes with precedence details.
|
|
229
|
+
|
|
230
|
+
"""
|
|
231
|
+
if not resolved:
|
|
232
|
+
return "No attributes found."
|
|
233
|
+
|
|
234
|
+
result = ["Resolved Attributes (with precedence):", "=" * 50, ""]
|
|
235
|
+
|
|
236
|
+
# Sort by attribute path for consistent output
|
|
237
|
+
for path in sorted(resolved.keys()):
|
|
238
|
+
info = resolved[path]
|
|
239
|
+
result.append(f"Attribute: {path}")
|
|
240
|
+
result.append(f" Value: {info['value']}")
|
|
241
|
+
result.append(
|
|
242
|
+
f" Precedence: {info['precedence']} (level {info['precedence_level']})"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if info["has_conflict"]:
|
|
246
|
+
result.append(f" ⚠️ Overridden values: {info['overridden_values']}")
|
|
247
|
+
|
|
248
|
+
result.append("") # Blank line between attributes
|
|
249
|
+
|
|
250
|
+
# Add summary
|
|
251
|
+
conflict_count = sum(1 for info in resolved.values() if info["has_conflict"])
|
|
252
|
+
result.append("=" * 50)
|
|
253
|
+
result.append(f"Total attributes: {len(resolved)}")
|
|
254
|
+
if conflict_count > 0:
|
|
255
|
+
result.append(f"Attributes with precedence conflicts: {conflict_count}")
|
|
256
|
+
|
|
257
|
+
return "\n".join(result)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Chef Habitat plan parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from souschef.core.constants import (
|
|
8
|
+
ERROR_FILE_NOT_FOUND,
|
|
9
|
+
ERROR_IS_DIRECTORY,
|
|
10
|
+
ERROR_PERMISSION_DENIED,
|
|
11
|
+
)
|
|
12
|
+
from souschef.core.path_utils import _normalize_path
|
|
13
|
+
|
|
14
|
+
# Maximum length for variable values in Habitat plan parsing
|
|
15
|
+
# Prevents ReDoS attacks from extremely long variable assignments
|
|
16
|
+
MAX_PLAN_VALUE_LENGTH = 10000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_habitat_plan(plan_path: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Parse a Chef Habitat plan file (plan.sh) and extract package metadata.
|
|
22
|
+
|
|
23
|
+
Analyzes Habitat plans to extract package information, dependencies,
|
|
24
|
+
ports, services, and build callbacks for container conversion.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
plan_path: Path to the plan.sh file
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
JSON string with parsed plan metadata
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
normalized_path = _normalize_path(plan_path)
|
|
35
|
+
if not normalized_path.exists():
|
|
36
|
+
return ERROR_FILE_NOT_FOUND.format(path=normalized_path)
|
|
37
|
+
if normalized_path.is_dir():
|
|
38
|
+
return ERROR_IS_DIRECTORY.format(path=normalized_path)
|
|
39
|
+
|
|
40
|
+
content = normalized_path.read_text(encoding="utf-8")
|
|
41
|
+
metadata: dict[str, Any] = {
|
|
42
|
+
"package": {},
|
|
43
|
+
"dependencies": {"build": [], "runtime": []},
|
|
44
|
+
"ports": [],
|
|
45
|
+
"binds": [],
|
|
46
|
+
"service": {},
|
|
47
|
+
"callbacks": {},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Extract package info
|
|
51
|
+
metadata["package"]["name"] = _extract_plan_var(content, "pkg_name")
|
|
52
|
+
metadata["package"]["origin"] = _extract_plan_var(content, "pkg_origin")
|
|
53
|
+
metadata["package"]["version"] = _extract_plan_var(content, "pkg_version")
|
|
54
|
+
metadata["package"]["maintainer"] = _extract_plan_var(content, "pkg_maintainer")
|
|
55
|
+
metadata["package"]["license"] = _extract_plan_array(content, "pkg_license")
|
|
56
|
+
metadata["package"]["description"] = _extract_plan_var(
|
|
57
|
+
content, "pkg_description"
|
|
58
|
+
)
|
|
59
|
+
metadata["package"]["upstream_url"] = _extract_plan_var(
|
|
60
|
+
content, "pkg_upstream_url"
|
|
61
|
+
)
|
|
62
|
+
metadata["package"]["source"] = _extract_plan_var(content, "pkg_source")
|
|
63
|
+
|
|
64
|
+
# Extract dependencies
|
|
65
|
+
metadata["dependencies"]["build"] = _extract_plan_array(
|
|
66
|
+
content, "pkg_build_deps"
|
|
67
|
+
)
|
|
68
|
+
metadata["dependencies"]["runtime"] = _extract_plan_array(content, "pkg_deps")
|
|
69
|
+
|
|
70
|
+
# Extract ports and bindings
|
|
71
|
+
metadata["ports"] = _extract_plan_exports(content, "pkg_exports")
|
|
72
|
+
metadata["binds"] = _extract_plan_exports(content, "pkg_binds_optional")
|
|
73
|
+
|
|
74
|
+
# Extract service config
|
|
75
|
+
metadata["service"]["run"] = _extract_plan_var(content, "pkg_svc_run")
|
|
76
|
+
metadata["service"]["user"] = _extract_plan_var(content, "pkg_svc_user")
|
|
77
|
+
metadata["service"]["group"] = _extract_plan_var(content, "pkg_svc_group")
|
|
78
|
+
|
|
79
|
+
# Extract callbacks
|
|
80
|
+
for callback in ["do_build", "do_install", "do_init", "do_setup_environment"]:
|
|
81
|
+
callback_content = _extract_plan_function(content, callback)
|
|
82
|
+
if callback_content:
|
|
83
|
+
metadata["callbacks"][callback] = callback_content
|
|
84
|
+
|
|
85
|
+
return json.dumps(metadata, indent=2)
|
|
86
|
+
except PermissionError:
|
|
87
|
+
return ERROR_PERMISSION_DENIED.format(path=plan_path)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return f"Error parsing Habitat plan: {e}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_plan_var(content: str, var_name: str) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Extract a variable value from a Habitat plan.
|
|
95
|
+
|
|
96
|
+
This helper supports both quoted and unquoted assignments and allows
|
|
97
|
+
escaped quotes within quoted values. If the variable is not found,
|
|
98
|
+
an empty string is returned.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
content: Full text of the Habitat plan.
|
|
102
|
+
var_name: Name of the variable to extract.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The extracted variable value, or an empty string if not present.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
# First, try to match a quoted value (single or double quotes), allowing
|
|
109
|
+
# escaped characters (e.g. \" or \') inside the value. We anchor at the
|
|
110
|
+
# start of the line to avoid partial matches elsewhere.
|
|
111
|
+
# Use a bounded, non-greedy quantifier to prevent ReDoS on malformed input.
|
|
112
|
+
quoted_pattern = (
|
|
113
|
+
rf'^{re.escape(var_name)}=(["\'])'
|
|
114
|
+
rf"(?P<value>(?:\\.|(?!\1).){{0,{MAX_PLAN_VALUE_LENGTH}}}?)\1"
|
|
115
|
+
)
|
|
116
|
+
match = re.search(quoted_pattern, content, re.MULTILINE | re.DOTALL)
|
|
117
|
+
if match:
|
|
118
|
+
return match.group("value").strip()
|
|
119
|
+
|
|
120
|
+
# Fallback: match an unquoted value up to the end of the line or a comment.
|
|
121
|
+
unquoted_pattern = rf"^{re.escape(var_name)}=([^\n#]+)"
|
|
122
|
+
match = re.search(unquoted_pattern, content, re.MULTILINE)
|
|
123
|
+
return match.group(1).strip() if match else ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_plan_array(content: str, var_name: str) -> list[str]:
|
|
127
|
+
"""Extract an array variable from a Habitat plan."""
|
|
128
|
+
# Find the start of the array declaration
|
|
129
|
+
pattern = rf"{var_name}=\("
|
|
130
|
+
match = re.search(pattern, content)
|
|
131
|
+
if not match:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
# Manually parse to handle nested parentheses correctly
|
|
135
|
+
start_pos = match.end()
|
|
136
|
+
paren_count = 1
|
|
137
|
+
end_pos = start_pos
|
|
138
|
+
|
|
139
|
+
# Find the matching closing parenthesis
|
|
140
|
+
while end_pos < len(content) and paren_count > 0:
|
|
141
|
+
if content[end_pos] == "(":
|
|
142
|
+
paren_count += 1
|
|
143
|
+
elif content[end_pos] == ")":
|
|
144
|
+
paren_count -= 1
|
|
145
|
+
end_pos += 1
|
|
146
|
+
|
|
147
|
+
if paren_count != 0:
|
|
148
|
+
# Unmatched parentheses
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
# Extract the content between parentheses (excluding the final closing paren)
|
|
152
|
+
array_content = content[start_pos : end_pos - 1]
|
|
153
|
+
|
|
154
|
+
# Split by newlines and process each line
|
|
155
|
+
elements = []
|
|
156
|
+
for line in array_content.strip().split("\n"):
|
|
157
|
+
# Remove inline comments
|
|
158
|
+
line = line.split("#")[0].strip()
|
|
159
|
+
if not line:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Remove quotes and whitespace
|
|
163
|
+
line = line.strip("\"'").strip()
|
|
164
|
+
if line:
|
|
165
|
+
elements.append(line)
|
|
166
|
+
|
|
167
|
+
return elements
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _extract_plan_exports(content: str, var_name: str) -> list[dict[str, str]]:
|
|
171
|
+
"""Extract port exports or bindings from a Habitat plan."""
|
|
172
|
+
# Find the start of the array declaration
|
|
173
|
+
pattern = rf"{var_name}=\("
|
|
174
|
+
match = re.search(pattern, content)
|
|
175
|
+
if not match:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
# Manually parse to handle nested parentheses correctly
|
|
179
|
+
start_pos = match.end()
|
|
180
|
+
paren_count = 1
|
|
181
|
+
end_pos = start_pos
|
|
182
|
+
|
|
183
|
+
# Find the matching closing parenthesis
|
|
184
|
+
while end_pos < len(content) and paren_count > 0:
|
|
185
|
+
if content[end_pos] == "(":
|
|
186
|
+
paren_count += 1
|
|
187
|
+
elif content[end_pos] == ")":
|
|
188
|
+
paren_count -= 1
|
|
189
|
+
end_pos += 1
|
|
190
|
+
|
|
191
|
+
if paren_count != 0:
|
|
192
|
+
# Unmatched parentheses
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
# Extract the content between parentheses (excluding the final closing paren)
|
|
196
|
+
exports_content = content[start_pos : end_pos - 1]
|
|
197
|
+
|
|
198
|
+
exports = []
|
|
199
|
+
for line in exports_content.strip().split("\n"):
|
|
200
|
+
export_match = re.search(r"\[([^\]]+)\]=([^\s]+)", line)
|
|
201
|
+
if export_match:
|
|
202
|
+
exports.append(
|
|
203
|
+
{"name": export_match.group(1), "value": export_match.group(2)}
|
|
204
|
+
)
|
|
205
|
+
return exports
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_quote_blocked(
|
|
209
|
+
ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""Check if a quote character is blocked by other active quotes."""
|
|
212
|
+
if ch == "'":
|
|
213
|
+
return in_double_quote or in_backtick
|
|
214
|
+
if ch == '"':
|
|
215
|
+
return in_single_quote or in_backtick
|
|
216
|
+
if ch == "`":
|
|
217
|
+
return in_single_quote or in_double_quote
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _toggle_quote(
|
|
222
|
+
ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
|
|
223
|
+
) -> tuple[bool, bool, bool]:
|
|
224
|
+
"""Toggle the appropriate quote state based on character."""
|
|
225
|
+
if ch == "'":
|
|
226
|
+
return not in_single_quote, in_double_quote, in_backtick
|
|
227
|
+
if ch == '"':
|
|
228
|
+
return in_single_quote, not in_double_quote, in_backtick
|
|
229
|
+
if ch == "`":
|
|
230
|
+
return in_single_quote, in_double_quote, not in_backtick
|
|
231
|
+
return in_single_quote, in_double_quote, in_backtick
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _update_quote_state(
|
|
235
|
+
ch: str,
|
|
236
|
+
in_single_quote: bool,
|
|
237
|
+
in_double_quote: bool,
|
|
238
|
+
in_backtick: bool,
|
|
239
|
+
escape_next: bool,
|
|
240
|
+
) -> tuple[bool, bool, bool, bool]:
|
|
241
|
+
"""Update quote tracking state for shell script parsing."""
|
|
242
|
+
# Handle escape sequences
|
|
243
|
+
if escape_next:
|
|
244
|
+
return in_single_quote, in_double_quote, in_backtick, False
|
|
245
|
+
|
|
246
|
+
if ch == "\\":
|
|
247
|
+
return in_single_quote, in_double_quote, in_backtick, True
|
|
248
|
+
|
|
249
|
+
# Handle quote characters
|
|
250
|
+
if ch in ("'", '"', "`") and not _is_quote_blocked(
|
|
251
|
+
ch, in_single_quote, in_double_quote, in_backtick
|
|
252
|
+
):
|
|
253
|
+
single, double, backtick = _toggle_quote(
|
|
254
|
+
ch, in_single_quote, in_double_quote, in_backtick
|
|
255
|
+
)
|
|
256
|
+
return single, double, backtick, False
|
|
257
|
+
|
|
258
|
+
return in_single_quote, in_double_quote, in_backtick, False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _extract_plan_function(content: str, func_name: str) -> str:
|
|
262
|
+
"""
|
|
263
|
+
Extract a shell function body from a Habitat plan.
|
|
264
|
+
|
|
265
|
+
Uses brace counting to handle nested braces in conditionals and loops.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
content: Full text of the Habitat plan.
|
|
269
|
+
func_name: Name of the function to extract.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The function body as a string, or an empty string if the function
|
|
273
|
+
is not found or is malformed.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
# Find the start of the function definition: func_name() {
|
|
277
|
+
func_def_pattern = rf"{re.escape(func_name)}\s*\(\)\s*{{"
|
|
278
|
+
match = re.search(func_def_pattern, content)
|
|
279
|
+
if not match:
|
|
280
|
+
return ""
|
|
281
|
+
|
|
282
|
+
start_index = match.end()
|
|
283
|
+
brace_count = 1
|
|
284
|
+
i = start_index
|
|
285
|
+
|
|
286
|
+
in_single_quote = False
|
|
287
|
+
in_double_quote = False
|
|
288
|
+
in_backtick = False
|
|
289
|
+
escape_next = False
|
|
290
|
+
|
|
291
|
+
while i < len(content):
|
|
292
|
+
ch = content[i]
|
|
293
|
+
|
|
294
|
+
# Update quote/escape state
|
|
295
|
+
(
|
|
296
|
+
in_single_quote,
|
|
297
|
+
in_double_quote,
|
|
298
|
+
in_backtick,
|
|
299
|
+
escape_next,
|
|
300
|
+
) = _update_quote_state(
|
|
301
|
+
ch, in_single_quote, in_double_quote, in_backtick, escape_next
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Count braces only when not inside quotes
|
|
305
|
+
if not (in_single_quote or in_double_quote or in_backtick):
|
|
306
|
+
if ch == "{":
|
|
307
|
+
brace_count += 1
|
|
308
|
+
elif ch == "}":
|
|
309
|
+
brace_count -= 1
|
|
310
|
+
if brace_count == 0:
|
|
311
|
+
body = content[start_index:i]
|
|
312
|
+
return body.strip("\n").strip()
|
|
313
|
+
|
|
314
|
+
i += 1
|
|
315
|
+
|
|
316
|
+
# Unbalanced braces or malformed definition
|
|
317
|
+
return ""
|