mcp-souschef 2.5.3__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
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":
@@ -626,11 +626,11 @@ def generate_canary_deployment_strategy(
626
626
  )
627
627
 
628
628
 
629
- def analyze_chef_application_patterns(
629
+ def analyse_chef_application_patterns(
630
630
  cookbook_path: str, application_type: str = "web_application"
631
631
  ) -> str:
632
632
  """
633
- Analyze cookbook deployment patterns and recommend Ansible strategies.
633
+ Analyse cookbook deployment patterns and recommend Ansible strategies.
634
634
 
635
635
  Detects blue/green, canary, rolling, or custom deployment approaches.
636
636
  Application type helps tune recommendations for web/database/service workloads.
@@ -647,7 +647,7 @@ def analyze_chef_application_patterns(
647
647
  )
648
648
 
649
649
  # Analyze cookbook for application patterns
650
- analysis = _analyze_application_cookbook(cookbook, application_type)
650
+ analysis = _analyse_application_cookbook(cookbook, application_type)
651
651
 
652
652
  return f"""# Chef Application Patterns Analysis
653
653
  # Cookbook: {cookbook.name}
@@ -685,9 +685,9 @@ def analyze_chef_application_patterns(
685
685
  # AWX Helper Functions
686
686
 
687
687
 
688
- def _analyze_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
688
+ def _analyse_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
689
689
  """
690
- Analyze recipes directory for AWX job steps.
690
+ Analyse recipes directory for AWX job steps.
691
691
 
692
692
  Args:
693
693
  cookbook_path: Path to cookbook root
@@ -710,11 +710,11 @@ def _analyze_recipes(cookbook_path: Path) -> list[dict[str, Any]]:
710
710
  return recipes
711
711
 
712
712
 
713
- def _analyze_attributes_for_survey(
713
+ def _analyse_attributes_for_survey(
714
714
  cookbook_path: Path,
715
715
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
716
716
  """
717
- Analyze attributes directory for survey field generation.
717
+ Analyse attributes directory for survey field generation.
718
718
 
719
719
  Args:
720
720
  cookbook_path: Path to cookbook root
@@ -748,7 +748,7 @@ def _analyze_attributes_for_survey(
748
748
  return attributes, survey_fields
749
749
 
750
750
 
751
- def _analyze_metadata_dependencies(cookbook_path: Path) -> list[str]:
751
+ def _analyse_metadata_dependencies(cookbook_path: Path) -> list[str]:
752
752
  """
753
753
  Extract cookbook dependencies from metadata.
754
754
 
@@ -795,9 +795,9 @@ def _collect_static_files(cookbook_path: Path) -> tuple[list[str], list[str]]:
795
795
  return templates, files
796
796
 
797
797
 
798
- 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:
799
799
  """
800
- Analyze Chef cookbook structure for AWX job template generation.
800
+ Analyse Chef cookbook structure for AWX job template generation.
801
801
 
802
802
  Orchestrates multiple analysis helpers to build comprehensive cookbook metadata.
803
803
 
@@ -810,9 +810,9 @@ def _analyze_cookbook_for_awx(cookbook_path: Path, cookbook_name: str) -> dict:
810
810
 
811
811
  """
812
812
  # Analyze each dimension independently
813
- recipes = _analyze_recipes(cookbook_path)
814
- attributes, survey_fields = _analyze_attributes_for_survey(cookbook_path)
815
- 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)
816
816
  templates, files = _collect_static_files(cookbook_path)
817
817
 
818
818
  # Assemble complete analysis
@@ -1155,8 +1155,8 @@ def _generate_survey_fields_from_attributes(attributes: dict) -> list:
1155
1155
  return survey_fields
1156
1156
 
1157
1157
 
1158
- def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1159
- """Analyze entire cookbooks directory structure."""
1158
+ def _analyse_cookbooks_directory(cookbooks_path: Path) -> dict:
1159
+ """Analyse entire cookbooks directory structure."""
1160
1160
  analysis: dict[str, Any] = {
1161
1161
  "total_cookbooks": 0,
1162
1162
  "cookbooks": {},
@@ -1172,7 +1172,7 @@ def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1172
1172
  cookbook_name = cookbook_dir.name
1173
1173
  analysis["total_cookbooks"] += 1
1174
1174
 
1175
- cookbook_analysis = _analyze_cookbook_for_awx(cookbook_dir, cookbook_name)
1175
+ cookbook_analysis = _analyse_cookbook_for_awx(cookbook_dir, cookbook_name)
1176
1176
  analysis["cookbooks"][cookbook_name] = cookbook_analysis
1177
1177
 
1178
1178
  # Aggregate stats
@@ -1186,8 +1186,8 @@ def _analyze_cookbooks_directory(cookbooks_path: Path) -> dict:
1186
1186
  # Deployment Strategy Helper Functions
1187
1187
 
1188
1188
 
1189
- def _analyze_chef_deployment_pattern(cookbook_path: Path) -> dict:
1190
- """Analyze Chef cookbook for deployment patterns."""
1189
+ def _analyse_chef_deployment_pattern(cookbook_path: Path) -> dict:
1190
+ """Analyse Chef cookbook for deployment patterns."""
1191
1191
  analysis: dict[str, Any] = {
1192
1192
  "deployment_steps": [],
1193
1193
  "health_checks": [],
@@ -1505,8 +1505,8 @@ def _assess_complexity_from_resource_count(resource_count: int) -> tuple[str, st
1505
1505
  return "medium", "2-3 weeks", "medium"
1506
1506
 
1507
1507
 
1508
- def _analyze_application_cookbook(cookbook_path: Path, app_type: str) -> dict:
1509
- """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."""
1510
1510
  analysis: dict[str, Any] = {
1511
1511
  "application_type": app_type,
1512
1512
  "deployment_patterns": [],
@@ -1650,7 +1650,7 @@ def _format_deployment_patterns(analysis: dict) -> str:
1650
1650
 
1651
1651
  def _format_chef_resources_analysis(analysis: dict) -> str:
1652
1652
  """Format Chef resources analysis."""
1653
- # Check for new format first (from _analyze_application_cookbook)
1653
+ # Check for new format first (from _analyse_application_cookbook)
1654
1654
  resources = analysis.get("resources", [])
1655
1655
  if resources:
1656
1656
  # Count resource types