mcp-souschef 2.2.0__py3-none-any.whl → 2.8.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.
@@ -2,9 +2,14 @@
2
2
 
3
3
  import ast
4
4
  import json
5
+ import re
5
6
  from collections.abc import Callable
6
7
  from typing import Any
7
8
 
9
+ from souschef.converters.cookbook_specific import (
10
+ build_cookbook_resource_params,
11
+ get_cookbook_package_config,
12
+ )
8
13
  from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
9
14
 
10
15
  # Type alias for parameter builder functions
@@ -41,6 +46,51 @@ def _parse_properties(properties_str: str) -> dict[str, Any]:
41
46
  return {}
42
47
 
43
48
 
49
+ def _normalize_template_value(value: Any) -> Any:
50
+ """Normalize Ruby-style attribute references into Jinja templates."""
51
+ if isinstance(value, str):
52
+ # Convert Chef node attributes to Ansible variables dynamically
53
+ def _replace_node_attr(match):
54
+ cookbook = match.group(1)
55
+ attr = match.group(2)
56
+
57
+ # Convert cookbook name to readable format
58
+ def char_to_word(c: str) -> str:
59
+ number_words = {
60
+ "1": "one",
61
+ "2": "two",
62
+ "3": "three",
63
+ "4": "four",
64
+ "5": "five",
65
+ "6": "six",
66
+ "7": "seven",
67
+ "8": "eight",
68
+ "9": "nine",
69
+ "0": "zero",
70
+ }
71
+ return number_words.get(c, c)
72
+
73
+ # Replace non-alphanumeric characters with underscores
74
+ readable_cookbook = "".join(
75
+ char_to_word(c) if c.isdigit() else (c if c.isalnum() else "_")
76
+ for c in cookbook
77
+ )
78
+
79
+ # Ensure we don't have multiple consecutive underscores
80
+ readable_cookbook = re.sub(r"_+", "_", readable_cookbook)
81
+ # Remove leading/trailing underscores
82
+ readable_cookbook = readable_cookbook.strip("_")
83
+
84
+ return f"{{{{ {readable_cookbook}_{attr} }}}}"
85
+
86
+ value = re.sub(r"node\['(\w+)'\]\['(\w+)'\]", _replace_node_attr, value)
87
+
88
+ # Wrap in Jinja if it's a node reference
89
+ if "node[" in value:
90
+ return f"{{{{ {value} }}}}"
91
+ return value
92
+
93
+
44
94
  def convert_resource_to_task(
45
95
  resource_type: str, resource_name: str, action: str = "create", properties: str = ""
46
96
  ) -> str:
@@ -188,6 +238,22 @@ def _get_remote_file_params(
188
238
  return params
189
239
 
190
240
 
241
+ def _get_include_recipe_params(
242
+ resource_name: str, action: str, props: dict[str, Any]
243
+ ) -> dict[str, Any]:
244
+ """
245
+ Build parameters for include_recipe resources.
246
+
247
+ Uses cookbook-specific configurations when available.
248
+ """
249
+ cookbook_config = get_cookbook_package_config(resource_name)
250
+ if cookbook_config:
251
+ # Return a copy to prevent callers from mutating the shared mapping.
252
+ return dict(cookbook_config["params"])
253
+ # Default behavior for recipes without a specific mapping.
254
+ return {"name": resource_name, "state": "present"}
255
+
256
+
191
257
  def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
192
258
  """Build default parameters for unknown resource types."""
193
259
  params = {"name": resource_name}
@@ -209,6 +275,7 @@ RESOURCE_PARAM_BUILDERS: dict[str, ParamBuilder | str] = {
209
275
  "user": _get_user_group_params,
210
276
  "group": _get_user_group_params,
211
277
  "remote_file": _get_remote_file_params,
278
+ "include_recipe": _get_include_recipe_params,
212
279
  }
213
280
 
214
281
 
@@ -229,7 +296,23 @@ def _convert_chef_resource_to_ansible(
229
296
 
230
297
  """
231
298
  # Get Ansible module name
232
- ansible_module = RESOURCE_MAPPINGS.get(resource_type, f"# Unknown: {resource_type}")
299
+ ansible_module = RESOURCE_MAPPINGS.get(resource_type)
300
+
301
+ # Check for cookbook-specific include_recipe configurations
302
+ if resource_type == "include_recipe":
303
+ cookbook_config = get_cookbook_package_config(resource_name)
304
+ if cookbook_config:
305
+ ansible_module = cookbook_config["module"]
306
+
307
+ # Handle unknown resource types
308
+ if ansible_module is None:
309
+ # Return a task with just a comment for unknown resources
310
+ return {
311
+ "name": f"Create {resource_type} {resource_name}",
312
+ "# Unknown": f"{resource_type}:",
313
+ "resource_name": resource_name,
314
+ "state": "present",
315
+ }
233
316
 
234
317
  # Start building the task
235
318
  task: dict[str, Any] = {
@@ -242,6 +325,12 @@ def _convert_chef_resource_to_ansible(
242
325
  # Build module parameters using appropriate builder
243
326
  module_params = _build_module_params(resource_type, resource_name, action, props)
244
327
 
328
+ # Override with cookbook-specific params for include_recipe
329
+ if resource_type == "include_recipe":
330
+ cookbook_config = get_cookbook_package_config(resource_name)
331
+ if cookbook_config:
332
+ module_params = cookbook_config["params"].copy()
333
+
245
334
  # Add special task-level flags for execute/bash resources
246
335
  if resource_type in ["execute", "bash"]:
247
336
  task["changed_when"] = "false"
@@ -266,6 +355,13 @@ def _build_module_params(
266
355
  Dictionary of Ansible module parameters.
267
356
 
268
357
  """
358
+ # First check for cookbook-specific resource types
359
+ cookbook_params = build_cookbook_resource_params(
360
+ resource_type, resource_name, action, props
361
+ )
362
+ if cookbook_params is not None:
363
+ return cookbook_params
364
+
269
365
  # Look up the parameter builder for this resource type
270
366
  builder = RESOURCE_PARAM_BUILDERS.get(resource_type)
271
367
 
@@ -297,6 +393,8 @@ def _format_dict_value(key: str, value: dict[str, Any]) -> list[str]:
297
393
  """Format a dictionary value for YAML output."""
298
394
  lines = [f" {key}:"]
299
395
  for param_key, param_value in value.items():
396
+ # Indent nested params by four spaces so downstream formatting nests
397
+ # module parameters under the module key.
300
398
  lines.append(f" {param_key}: {_format_yaml_value(param_value)}")
301
399
  return lines
302
400
 
@@ -317,6 +415,10 @@ def _format_ansible_task(task: dict[str, Any]) -> str:
317
415
  for key, value in task.items():
318
416
  if key == "name":
319
417
  continue
418
+ if key == "# Unknown":
419
+ # Handle unknown resources with a comment
420
+ result.append(f" # {value}")
421
+ continue
320
422
  if isinstance(value, dict):
321
423
  result.extend(_format_dict_value(key, value))
322
424
  else:
@@ -1,10 +1,13 @@
1
1
  """Constants used throughout SousChef."""
2
2
 
3
3
  __all__ = [
4
+ "VERSION",
4
5
  "ANSIBLE_SERVICE_MODULE",
5
6
  "METADATA_FILENAME",
6
7
  "ERROR_PREFIX",
7
8
  "NODE_PREFIX",
9
+ "ATTRIBUTE_PREFIX",
10
+ "VALUE_PREFIX",
8
11
  "CHEF_RECIPE_PREFIX",
9
12
  "CHEF_ROLE_PREFIX",
10
13
  "REGEX_WHITESPACE_QUOTE",
@@ -39,6 +42,9 @@ __all__ = [
39
42
  "ACTION_TO_STATE",
40
43
  ]
41
44
 
45
+ # Version
46
+ VERSION = "2.6.0"
47
+
42
48
  # Ansible module names
43
49
  ANSIBLE_SERVICE_MODULE = "ansible.builtin.service"
44
50
 
@@ -48,6 +54,8 @@ METADATA_FILENAME = "metadata.rb"
48
54
  # Common prefixes
49
55
  ERROR_PREFIX = "Error:"
50
56
  NODE_PREFIX = "node["
57
+ ATTRIBUTE_PREFIX = "Attribute: "
58
+ VALUE_PREFIX = "Value: "
51
59
  CHEF_RECIPE_PREFIX = "recipe["
52
60
  CHEF_ROLE_PREFIX = "role["
53
61
 
@@ -126,6 +134,11 @@ RESOURCE_MAPPINGS = {
126
134
  "mount": "ansible.builtin.mount",
127
135
  "git": "ansible.builtin.git",
128
136
  "remote_file": "ansible.builtin.get_url",
137
+ "nodejs_npm": "community.general.npm",
138
+ # Map include_recipe to a generic package install so well-known recipes
139
+ # (like nodejs) can be materialised into concrete tasks instead of
140
+ # producing stubbed import_role calls.
141
+ "include_recipe": "ansible.builtin.package",
129
142
  }
130
143
 
131
144
  # Chef action to Ansible state mappings
@@ -7,10 +7,8 @@ def _normalize_path(path_str: str) -> Path:
7
7
  """
8
8
  Normalize a file path for safe filesystem operations.
9
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.
10
+ This function validates input and resolves relative paths and symlinks
11
+ to absolute paths, preventing path traversal attacks (CWE-23).
14
12
 
15
13
  Args:
16
14
  path_str: Path string to normalize.
@@ -19,17 +17,22 @@ def _normalize_path(path_str: str) -> Path:
19
17
  Resolved absolute Path object.
20
18
 
21
19
  Raises:
22
- ValueError: If the path contains null bytes or is invalid.
20
+ ValueError: If the path contains null bytes, traversal attempts, or is invalid.
23
21
 
24
22
  """
23
+ if not isinstance(path_str, str):
24
+ raise ValueError(f"Path must be a string, got {type(path_str)}")
25
+
26
+ # Reject paths with null bytes
25
27
  if "\x00" in path_str:
26
28
  raise ValueError(f"Path contains null bytes: {path_str!r}")
27
29
 
30
+ # Reject paths with obvious directory traversal attempts
31
+ if ".." in path_str:
32
+ raise ValueError(f"Path contains directory traversal: {path_str!r}")
33
+
28
34
  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]
35
+ # Resolve to absolute path, removing ., and resolving symlinks
33
36
  return Path(path_str).resolve()
34
37
  except (OSError, RuntimeError) as e:
35
38
  raise ValueError(f"Invalid path {path_str}: {e}") from e
@@ -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 "---" in result:
238
- # Ansible assert format
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.
souschef/deployment.py CHANGED
@@ -51,7 +51,7 @@ def generate_awx_job_template_from_cookbook(
51
51
  )
52
52
 
53
53
  cookbook = validate_cookbook_structure(cookbook_path)
54
- cookbook_analysis = _analyze_cookbook_for_awx(cookbook, cookbook_name)
54
+ cookbook_analysis = _analyse_cookbook_for_awx(cookbook, cookbook_name)
55
55
  job_template = _generate_awx_job_template(
56
56
  cookbook_analysis, cookbook_name, target_environment, include_survey
57
57
  )
@@ -184,7 +184,7 @@ def generate_awx_project_from_cookbooks(
184
184
  )
185
185
 
186
186
  # Analyze all cookbooks
187
- cookbooks_analysis = _analyze_cookbooks_directory(cookbooks_path)
187
+ cookbooks_analysis = _analyse_cookbooks_directory(cookbooks_path)
188
188
 
189
189
  # Generate project structure
190
190
  project_config = _generate_awx_project_config(project_name, scm_type, scm_url)
@@ -329,7 +329,7 @@ def convert_chef_deployment_to_ansible_strategy(
329
329
  )
330
330
 
331
331
  # Analyze Chef deployment pattern
332
- pattern_analysis = _analyze_chef_deployment_pattern(cookbook)
332
+ pattern_analysis = _analyse_chef_deployment_pattern(cookbook)
333
333
 
334
334
  # Determine best strategy if auto-detect
335
335
  if deployment_pattern == "auto":
@@ -474,14 +474,16 @@ def _validate_canary_inputs(
474
474
  raise ValueError("Steps must be between 1 and 100")
475
475
  if steps != sorted(steps):
476
476
  return None, (
477
- f"Error: Rollout steps must be in ascending order: {rollout_steps}\n\n"
477
+ "Error: Rollout steps must be in ascending order: "
478
+ f"{rollout_steps}\n\n"
478
479
  "Suggestion: Use format like '10,25,50,100'"
479
480
  )
480
481
  return steps, None
481
482
  except ValueError as e:
482
- return None, (
483
+ return (
484
+ None,
483
485
  f"Error: Invalid rollout steps '{rollout_steps}': {e}\n\n"
484
- "Suggestion: Use comma-separated percentages like '10,25,50,100'"
486
+ "Suggestion: Use comma-separated percentages like '10,25,50,100'",
485
487
  )
486
488
 
487
489
 
@@ -624,11 +626,11 @@ def generate_canary_deployment_strategy(
624
626
  )
625
627
 
626
628
 
627
- def analyze_chef_application_patterns(
629
+ def analyse_chef_application_patterns(
628
630
  cookbook_path: str, application_type: str = "web_application"
629
631
  ) -> str:
630
632
  """
631
- Analyze cookbook deployment patterns and recommend Ansible strategies.
633
+ Analyse cookbook deployment patterns and recommend Ansible strategies.
632
634
 
633
635
  Detects blue/green, canary, rolling, or custom deployment approaches.
634
636
  Application type helps tune recommendations for web/database/service workloads.
@@ -645,7 +647,7 @@ def analyze_chef_application_patterns(
645
647
  )
646
648
 
647
649
  # Analyze cookbook for application patterns
648
- analysis = _analyze_application_cookbook(cookbook, application_type)
650
+ analysis = _analyse_application_cookbook(cookbook, application_type)
649
651
 
650
652
  return f"""# Chef Application Patterns Analysis
651
653
  # Cookbook: {cookbook.name}
@@ -683,9 +685,9 @@ def analyze_chef_application_patterns(
683
685
  # AWX Helper Functions
684
686
 
685
687
 
686
- def _analyze_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
688
+ def _analyse_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
687
689
  """
688
- Analyze recipes directory for AWX job steps.
690
+ Analyse recipes directory for AWX job steps.
689
691
 
690
692
  Args:
691
693
  cookbook_path: Path to cookbook root
@@ -708,11 +710,11 @@ def _analyze_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
708
710
  return recipes
709
711
 
710
712
 
711
- def _analyze_attributes_for_survey(
713
+ def _analyse_attributes_for_survey(
712
714
  cookbook_path: Path,
713
715
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
714
716
  """
715
- Analyze attributes directory for survey field generation.
717
+ Analyse attributes directory for survey field generation.
716
718
 
717
719
  Args:
718
720
  cookbook_path: Path to cookbook root
@@ -746,7 +748,7 @@ def _analyze_attributes_for_survey(
746
748
  return attributes, survey_fields
747
749
 
748
750
 
749
- def _analyze_metadata_dependencies(cookbook_path: Path) -> list[str]:
751
+ def _analyse_metadata_dependencies(cookbook_path: Path) -> list[str]:
750
752
  """
751
753
  Extract cookbook dependencies from metadata.
752
754
 
@@ -793,9 +795,9 @@ def _collect_static_files(cookbook_path: Path) -> tuple[list[str], list[str]]:
793
795
  return templates, files
794
796
 
795
797
 
796
- def _analyze_cookbook_for_awx(cookbook_path: Path, cookbook_name: str) -> dict:
798
+ def _analyse_cookbook_for_awx(cookbook_path: Path, cookbook_name: str) -> dict:
797
799
  """
798
- Analyze Chef cookbook structure for AWX job template generation.
800
+ Analyse Chef cookbook structure for AWX job template generation.
799
801
 
800
802
  Orchestrates multiple analysis helpers to build comprehensive cookbook metadata.
801
803
 
@@ -808,9 +810,9 @@ def _analyze_cookbook_for_awx(cookbook_path: Path, cookbook_name: str) -> dict:
808
810
 
809
811
  """
810
812
  # Analyze each dimension independently
811
- recipes = _analyze_recipes(cookbook_path)
812
- attributes, survey_fields = _analyze_attributes_for_survey(cookbook_path)
813
- dependencies = _analyze_metadata_dependencies(cookbook_path)
813
+ recipes = _analyse_recipes(cookbook_path)
814
+ attributes, survey_fields = _analyse_attributes_for_survey(cookbook_path)
815
+ dependencies = _analyse_metadata_dependencies(cookbook_path)
814
816
  templates, files = _collect_static_files(cookbook_path)
815
817
 
816
818
  # Assemble complete analysis
@@ -1153,8 +1155,8 @@ def _generate_survey_fields_from_attributes(attributes: dict) -> list:
1153
1155
  return survey_fields
1154
1156
 
1155
1157
 
1156
- def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1157
- """Analyze entire cookbooks directory structure."""
1158
+ def _analyse_cookbooks_directory(cookbooks_path: Path) -> dict:
1159
+ """Analyse entire cookbooks directory structure."""
1158
1160
  analysis: dict[str, Any] = {
1159
1161
  "total_cookbooks": 0,
1160
1162
  "cookbooks": {},
@@ -1170,7 +1172,7 @@ def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1170
1172
  cookbook_name = cookbook_dir.name
1171
1173
  analysis["total_cookbooks"] += 1
1172
1174
 
1173
- cookbook_analysis = _analyze_cookbook_for_awx(cookbook_dir, cookbook_name)
1175
+ cookbook_analysis = _analyse_cookbook_for_awx(cookbook_dir, cookbook_name)
1174
1176
  analysis["cookbooks"][cookbook_name] = cookbook_analysis
1175
1177
 
1176
1178
  # Aggregate stats
@@ -1184,8 +1186,8 @@ def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1184
1186
  # Deployment Strategy Helper Functions
1185
1187
 
1186
1188
 
1187
- def _analyze_chef_deployment_pattern(cookbook_path: Path) -> dict:
1188
- """Analyze Chef cookbook for deployment patterns."""
1189
+ def _analyse_chef_deployment_pattern(cookbook_path: Path) -> dict:
1190
+ """Analyse Chef cookbook for deployment patterns."""
1189
1191
  analysis: dict[str, Any] = {
1190
1192
  "deployment_steps": [],
1191
1193
  "health_checks": [],
@@ -1503,8 +1505,8 @@ def _assess_complexity_from_resource_count(resource_count: int) -> tuple[str, st
1503
1505
  return "medium", "2-3 weeks", "medium"
1504
1506
 
1505
1507
 
1506
- def _analyze_application_cookbook(cookbook_path: Path, app_type: str) -> dict:
1507
- """Analyze Chef cookbook for application deployment patterns."""
1508
+ def _analyse_application_cookbook(cookbook_path: Path, app_type: str) -> dict:
1509
+ """Analyse Chef cookbook for application deployment patterns."""
1508
1510
  analysis: dict[str, Any] = {
1509
1511
  "application_type": app_type,
1510
1512
  "deployment_patterns": [],
@@ -1648,7 +1650,7 @@ def _format_deployment_patterns(analysis: dict) -> str:
1648
1650
 
1649
1651
  def _format_chef_resources_analysis(analysis: dict) -> str:
1650
1652
  """Format Chef resources analysis."""
1651
- # Check for new format first (from _analyze_application_cookbook)
1653
+ # Check for new format first (from _analyse_application_cookbook)
1652
1654
  resources = analysis.get("resources", [])
1653
1655
  if resources:
1654
1656
  # Count resource types
@@ -1,7 +1,5 @@
1
1
  """Filesystem operations for Chef cookbook exploration."""
2
2
 
3
- from mcp.server.fastmcp import FastMCP
4
-
5
3
  from souschef.core.constants import (
6
4
  ERROR_FILE_NOT_FOUND,
7
5
  ERROR_IS_DIRECTORY,
@@ -9,11 +7,7 @@ from souschef.core.constants import (
9
7
  )
10
8
  from souschef.core.path_utils import _normalize_path
11
9
 
12
- # Get MCP instance to register tools
13
- mcp = FastMCP("souschef")
14
-
15
10
 
16
- @mcp.tool()
17
11
  def list_directory(path: str) -> list[str] | str:
18
12
  """
19
13
  List the contents of a directory.
@@ -40,7 +34,6 @@ def list_directory(path: str) -> list[str] | str:
40
34
  return f"An error occurred: {e}"
41
35
 
42
36
 
43
- @mcp.tool()
44
37
  def read_file(path: str) -> str:
45
38
  """
46
39
  Read the contents of a file.
@@ -13,7 +13,11 @@ from souschef.parsers.inspec import (
13
13
  generate_inspec_from_chef,
14
14
  parse_inspec_profile,
15
15
  )
16
- from souschef.parsers.metadata import list_cookbook_structure, read_cookbook_metadata
16
+ from souschef.parsers.metadata import (
17
+ list_cookbook_structure,
18
+ parse_cookbook_metadata,
19
+ read_cookbook_metadata,
20
+ )
17
21
  from souschef.parsers.recipe import parse_recipe
18
22
  from souschef.parsers.resource import parse_custom_resource
19
23
  from souschef.parsers.template import parse_template
@@ -24,6 +28,7 @@ __all__ = [
24
28
  "parse_attributes",
25
29
  "parse_custom_resource",
26
30
  "read_cookbook_metadata",
31
+ "parse_cookbook_metadata",
27
32
  "list_cookbook_structure",
28
33
  "parse_inspec_profile",
29
34
  "convert_inspec_to_test",