mcp-souschef 2.5.3__py3-none-any.whl → 3.0.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.
souschef/server.py CHANGED
@@ -10,7 +10,7 @@ from mcp.server.fastmcp import FastMCP
10
10
 
11
11
  # Import assessment functions with aliases to avoid name conflicts
12
12
  from souschef.assessment import (
13
- analyze_cookbook_dependencies as _analyze_cookbook_dependencies,
13
+ analyse_cookbook_dependencies as _analyse_cookbook_dependencies,
14
14
  )
15
15
  from souschef.assessment import (
16
16
  assess_chef_migration_complexity as _assess_chef_migration_complexity,
@@ -79,7 +79,7 @@ from souschef.converters.playbook import ( # noqa: F401
79
79
 
80
80
  # Import playbook converter functions
81
81
  from souschef.converters.playbook import (
82
- analyze_chef_search_patterns as _analyze_chef_search_patterns,
82
+ analyse_chef_search_patterns as _analyse_chef_search_patterns,
83
83
  )
84
84
  from souschef.converters.playbook import (
85
85
  convert_chef_search_to_inventory as _convert_chef_search_to_inventory,
@@ -139,8 +139,8 @@ from souschef.core.validation import ( # noqa: F401
139
139
  # Note: MCP tool wrappers exist for some of these, but tests import directly
140
140
  # codeql[py/unused-import]: Backward compatibility exports for test suite
141
141
  from souschef.deployment import ( # noqa: F401
142
- _analyze_cookbook_for_awx,
143
- _analyze_cookbooks_directory,
142
+ _analyse_cookbook_for_awx,
143
+ _analyse_cookbooks_directory,
144
144
  _detect_deployment_patterns_in_recipe,
145
145
  _extract_cookbook_attributes,
146
146
  _extract_cookbook_dependencies,
@@ -151,7 +151,7 @@ from souschef.deployment import ( # noqa: F401
151
151
  _generate_survey_fields_from_attributes,
152
152
  _parse_chef_runlist,
153
153
  _recommend_ansible_strategies,
154
- analyze_chef_application_patterns,
154
+ analyse_chef_application_patterns,
155
155
  convert_chef_deployment_to_ansible_strategy,
156
156
  generate_awx_inventory_source_from_chef,
157
157
  generate_awx_job_template_from_cookbook,
@@ -163,9 +163,6 @@ from souschef.deployment import ( # noqa: F401
163
163
 
164
164
  # Re-exports for backward compatibility (used by tests)
165
165
  # These are imported and re-exported intentionally
166
- from souschef.deployment import (
167
- analyze_chef_application_patterns as _analyze_chef_application_patterns,
168
- )
169
166
  from souschef.deployment import (
170
167
  convert_chef_deployment_to_ansible_strategy as _convert_chef_deployment_to_ansible_strategy,
171
168
  )
@@ -285,6 +282,9 @@ ERROR_FILE_NOT_FOUND = "Error: File not found at {path}"
285
282
  ERROR_IS_DIRECTORY = "Error: {path} is a directory, not a file"
286
283
  ERROR_PERMISSION_DENIED = "Error: Permission denied for {path}"
287
284
 
285
+ # File constants
286
+ METADATA_RB = "metadata.rb"
287
+
288
288
  # Validation Framework Classes
289
289
 
290
290
 
@@ -903,9 +903,9 @@ def generate_ansible_vault_from_databags(
903
903
 
904
904
 
905
905
  @mcp.tool()
906
- def analyze_chef_databag_usage(cookbook_path: str, databags_path: str = "") -> str:
906
+ def analyse_chef_databag_usage(cookbook_path: str, databags_path: str = "") -> str:
907
907
  """
908
- Analyze Chef cookbook for data bag usage and provide migration recommendations.
908
+ Analyse Chef cookbook for data bag usage and provide migration recommendations.
909
909
 
910
910
  Args:
911
911
  cookbook_path: Path to Chef cookbook
@@ -928,7 +928,7 @@ def analyze_chef_databag_usage(cookbook_path: str, databags_path: str = "") -> s
928
928
  if databags_path:
929
929
  databags = _normalize_path(databags_path)
930
930
  if databags.exists():
931
- databag_structure = _analyze_databag_structure(databags)
931
+ databag_structure = _analyse_databag_structure(databags)
932
932
 
933
933
  # Generate recommendations
934
934
  recommendations = _generate_databag_migration_recommendations(
@@ -1064,11 +1064,11 @@ def generate_inventory_from_chef_environments(
1064
1064
 
1065
1065
 
1066
1066
  @mcp.tool()
1067
- def analyze_chef_environment_usage(
1067
+ def analyse_chef_environment_usage(
1068
1068
  cookbook_path: str, environments_path: str = ""
1069
1069
  ) -> str:
1070
1070
  """
1071
- Analyze Chef cookbook for environment usage.
1071
+ Analyse Chef cookbook for environment usage.
1072
1072
 
1073
1073
  Provides migration recommendations.
1074
1074
 
@@ -1093,7 +1093,7 @@ def analyze_chef_environment_usage(
1093
1093
  if environments_path:
1094
1094
  environments = _normalize_path(environments_path)
1095
1095
  if environments.exists():
1096
- environment_structure = _analyze_environments_structure(environments)
1096
+ environment_structure = _analyse_environments_structure(environments)
1097
1097
 
1098
1098
  # Generate recommendations
1099
1099
  recommendations = _generate_environment_migration_recommendations(
@@ -1646,8 +1646,8 @@ def _find_environment_patterns_in_content(content: str, file_path: str) -> list:
1646
1646
  return patterns
1647
1647
 
1648
1648
 
1649
- def _analyze_environments_structure(environments_path) -> dict:
1650
- """Analyze the structure of Chef environments directory."""
1649
+ def _analyse_environments_structure(environments_path) -> dict:
1650
+ """Analyse the structure of Chef environments directory."""
1651
1651
  structure: dict[str, Any] = {"total_environments": 0, "environments": {}}
1652
1652
 
1653
1653
  for env_file in environments_path.glob("*.rb"):
@@ -1679,8 +1679,8 @@ def _analyze_environments_structure(environments_path) -> dict:
1679
1679
  return structure
1680
1680
 
1681
1681
 
1682
- def _analyze_usage_pattern_recommendations(usage_patterns: list) -> list[str]:
1683
- """Analyze usage patterns and generate recommendations."""
1682
+ def _analyse_usage_pattern_recommendations(usage_patterns: list) -> list[str]:
1683
+ """Analyse usage patterns and generate recommendations."""
1684
1684
  if not usage_patterns:
1685
1685
  return []
1686
1686
 
@@ -1711,8 +1711,8 @@ def _analyze_usage_pattern_recommendations(usage_patterns: list) -> list[str]:
1711
1711
  return recommendations
1712
1712
 
1713
1713
 
1714
- def _analyze_structure_recommendations(env_structure: dict) -> list[str]:
1715
- """Analyze environment structure and generate recommendations."""
1714
+ def _analyse_structure_recommendations(env_structure: dict) -> list[str]:
1715
+ """Analyse environment structure and generate recommendations."""
1716
1716
  if not env_structure:
1717
1717
  return []
1718
1718
 
@@ -1760,8 +1760,8 @@ def _generate_environment_migration_recommendations(
1760
1760
  ) -> str:
1761
1761
  """Generate migration recommendations based on environment usage analysis."""
1762
1762
  recommendations = []
1763
- recommendations.extend(_analyze_usage_pattern_recommendations(usage_patterns))
1764
- recommendations.extend(_analyze_structure_recommendations(env_structure))
1763
+ recommendations.extend(_analyse_usage_pattern_recommendations(usage_patterns))
1764
+ recommendations.extend(_analyse_structure_recommendations(env_structure))
1765
1765
  recommendations.extend(_get_general_migration_recommendations())
1766
1766
 
1767
1767
  return "\n".join(recommendations)
@@ -2090,8 +2090,8 @@ def _find_databag_patterns_in_content(content: str, file_path: str) -> list:
2090
2090
  return patterns
2091
2091
 
2092
2092
 
2093
- def _analyze_databag_structure(databags_path) -> dict:
2094
- """Analyze the structure of Chef data bags directory."""
2093
+ def _analyse_databag_structure(databags_path) -> dict:
2094
+ """Analyse the structure of Chef data bags directory."""
2095
2095
  structure: dict[str, Any] = {
2096
2096
  "total_databags": 0,
2097
2097
  "total_items": 0,
@@ -2135,9 +2135,9 @@ def _analyze_databag_structure(databags_path) -> dict:
2135
2135
  return structure
2136
2136
 
2137
2137
 
2138
- def _analyze_usage_patterns(usage_patterns: list) -> list[str]:
2138
+ def _analyse_usage_patterns(usage_patterns: list) -> list[str]:
2139
2139
  """
2140
- Analyze databag usage patterns and generate recommendations.
2140
+ Analyse databag usage patterns and generate recommendations.
2141
2141
 
2142
2142
  Args:
2143
2143
  usage_patterns: List of usage pattern dicts
@@ -2178,9 +2178,9 @@ def _analyze_usage_patterns(usage_patterns: list) -> list[str]:
2178
2178
  return recommendations
2179
2179
 
2180
2180
 
2181
- def _analyze_databag_structure_recommendations(databag_structure: dict) -> list[str]:
2181
+ def _analyse_databag_structure_recommendations(databag_structure: dict) -> list[str]:
2182
2182
  """
2183
- Analyze databag structure and generate recommendations.
2183
+ Analyse databag structure and generate recommendations.
2184
2184
 
2185
2185
  Args:
2186
2186
  databag_structure: Dict with structure analysis
@@ -2245,11 +2245,11 @@ def _generate_databag_migration_recommendations(
2245
2245
  recommendations = []
2246
2246
 
2247
2247
  # Analyze usage patterns
2248
- recommendations.extend(_analyze_usage_patterns(usage_patterns))
2248
+ recommendations.extend(_analyse_usage_patterns(usage_patterns))
2249
2249
 
2250
2250
  # Analyze structure
2251
2251
  recommendations.extend(
2252
- _analyze_databag_structure_recommendations(databag_structure)
2252
+ _analyse_databag_structure_recommendations(databag_structure)
2253
2253
  )
2254
2254
 
2255
2255
  # Add variable scope best practices
@@ -2329,7 +2329,7 @@ mcp.tool()(_generate_awx_inventory_source_from_chef)
2329
2329
  mcp.tool()(_convert_chef_deployment_to_ansible_strategy)
2330
2330
  mcp.tool()(_generate_blue_green_deployment_playbook)
2331
2331
  mcp.tool()(_generate_canary_deployment_strategy)
2332
- mcp.tool()(_analyze_chef_application_patterns)
2332
+ mcp.tool()(analyse_chef_application_patterns)
2333
2333
 
2334
2334
 
2335
2335
  # ============================================================================
@@ -2389,9 +2389,9 @@ def generate_migration_plan(
2389
2389
 
2390
2390
 
2391
2391
  @mcp.tool()
2392
- def analyze_cookbook_dependencies(cookbook_paths: str) -> str:
2392
+ def analyse_cookbook_dependencies(cookbook_paths: str) -> str:
2393
2393
  """
2394
- Analyze dependencies between Chef cookbooks.
2394
+ Analyse dependencies between Chef cookbooks.
2395
2395
 
2396
2396
  Maps cookbook dependencies, identifies circular dependencies, and
2397
2397
  recommends migration order.
@@ -2403,7 +2403,7 @@ def analyze_cookbook_dependencies(cookbook_paths: str) -> str:
2403
2403
  Dependency analysis report in markdown format.
2404
2404
 
2405
2405
  """
2406
- return _analyze_cookbook_dependencies(cookbook_paths)
2406
+ return _analyse_cookbook_dependencies(cookbook_paths)
2407
2407
 
2408
2408
 
2409
2409
  @mcp.tool()
@@ -2559,9 +2559,10 @@ def generate_dynamic_inventory_script(search_queries: str) -> str:
2559
2559
  return _generate_dynamic_inventory_script(search_queries)
2560
2560
 
2561
2561
 
2562
- def analyze_chef_search_patterns(recipe_or_cookbook_path: str) -> str:
2562
+ @mcp.tool()
2563
+ def analyse_chef_search_patterns(recipe_or_cookbook_path: str) -> str:
2563
2564
  """
2564
- Analyze Chef search patterns in recipe or cookbook.
2565
+ Analyse Chef search patterns in recipe or cookbook.
2565
2566
 
2566
2567
  Args:
2567
2568
  recipe_or_cookbook_path: Path to recipe or cookbook.
@@ -2570,7 +2571,7 @@ def analyze_chef_search_patterns(recipe_or_cookbook_path: str) -> str:
2570
2571
  Analysis of search patterns found.
2571
2572
 
2572
2573
  """
2573
- return _analyze_chef_search_patterns(recipe_or_cookbook_path)
2574
+ return _analyse_chef_search_patterns(recipe_or_cookbook_path)
2574
2575
 
2575
2576
 
2576
2577
  @mcp.tool()
@@ -2811,6 +2812,683 @@ def parse_chef_migration_assessment(
2811
2812
  )
2812
2813
 
2813
2814
 
2815
+ @mcp.tool()
2816
+ def convert_cookbook_comprehensive(
2817
+ cookbook_path: str,
2818
+ output_path: str,
2819
+ assessment_data: str = "",
2820
+ include_templates: bool = True,
2821
+ include_attributes: bool = True,
2822
+ include_recipes: bool = True,
2823
+ role_name: str = "",
2824
+ ) -> str:
2825
+ """
2826
+ Convert an entire Chef cookbook to a complete Ansible role.
2827
+
2828
+ This function performs comprehensive conversion of a Chef cookbook to an Ansible role,
2829
+ including recipes, templates, attributes, and proper role structure. It can use
2830
+ assessment data to optimize the conversion process.
2831
+
2832
+ Args:
2833
+ cookbook_path: Path to the Chef cookbook directory
2834
+ output_path: Directory where the Ansible role will be created
2835
+ assessment_data: Optional JSON string with assessment results for optimization
2836
+ include_templates: Whether to convert ERB templates to Jinja2 (default: True)
2837
+ include_attributes: Whether to convert attributes to Ansible variables (default: True)
2838
+ include_recipes: Whether to convert recipes to Ansible tasks (default: True)
2839
+ role_name: Name for the Ansible role (defaults to cookbook name)
2840
+
2841
+ Returns:
2842
+ Summary of the conversion process and created files
2843
+
2844
+ """
2845
+ try:
2846
+ from souschef.core.path_utils import _normalize_path
2847
+
2848
+ cookbook_dir = _normalize_path(cookbook_path)
2849
+ output_dir = _normalize_path(output_path)
2850
+
2851
+ if not cookbook_dir.exists():
2852
+ return f"Error: Cookbook path does not exist: {cookbook_path}"
2853
+
2854
+ # Parse assessment data if provided
2855
+ assessment = {}
2856
+ if assessment_data:
2857
+ try:
2858
+ assessment = json.loads(assessment_data)
2859
+ except json.JSONDecodeError:
2860
+ return f"Error: Invalid assessment data JSON: {assessment_data}"
2861
+
2862
+ # Get cookbook metadata and setup
2863
+ cookbook_name, role_name = _setup_conversion_metadata(cookbook_dir, role_name)
2864
+
2865
+ # Create role directory structure
2866
+ role_dir = _create_role_structure(output_dir, role_name)
2867
+
2868
+ # Initialize conversion summary
2869
+ conversion_summary = {
2870
+ "cookbook_name": cookbook_name,
2871
+ "role_name": role_name,
2872
+ "converted_files": [],
2873
+ "errors": [],
2874
+ "warnings": [],
2875
+ }
2876
+
2877
+ # Convert components
2878
+ if include_recipes:
2879
+ _convert_recipes(cookbook_dir, role_dir, conversion_summary)
2880
+
2881
+ if include_templates:
2882
+ _convert_templates(cookbook_dir, role_dir, conversion_summary)
2883
+
2884
+ if include_attributes:
2885
+ _convert_attributes(cookbook_dir, role_dir, conversion_summary)
2886
+
2887
+ # Create main task file and metadata
2888
+ _create_main_task_file(
2889
+ cookbook_dir, role_dir, conversion_summary, include_recipes
2890
+ )
2891
+ _create_role_metadata(
2892
+ role_dir, role_name, cookbook_name, assessment, conversion_summary
2893
+ )
2894
+
2895
+ # Generate and return summary report
2896
+ return _generate_conversion_report(conversion_summary, role_dir)
2897
+
2898
+ except Exception as e:
2899
+ return format_error_with_context(
2900
+ e, "converting cookbook comprehensively", cookbook_path
2901
+ )
2902
+
2903
+
2904
+ def _setup_conversion_metadata(cookbook_dir: Path, role_name: str) -> tuple[str, str]:
2905
+ """Get cookbook metadata and determine role name."""
2906
+ metadata_file = cookbook_dir / METADATA_RB
2907
+ cookbook_name = cookbook_dir.name
2908
+ if metadata_file.exists():
2909
+ metadata = _parse_cookbook_metadata(str(metadata_file))
2910
+ name_from_metadata = metadata.get("name")
2911
+ if name_from_metadata is not None:
2912
+ cookbook_name = str(name_from_metadata)
2913
+
2914
+ if not role_name:
2915
+ role_name = cookbook_name
2916
+
2917
+ return cookbook_name, role_name
2918
+
2919
+
2920
+ def _create_role_structure(output_dir: Path, role_name: str) -> Path:
2921
+ """Create the standard Ansible role directory structure."""
2922
+ role_dir = output_dir / role_name
2923
+ role_tasks_dir = role_dir / "tasks"
2924
+ role_templates_dir = role_dir / "templates"
2925
+ role_vars_dir = role_dir / "vars"
2926
+ role_defaults_dir = role_dir / "defaults"
2927
+
2928
+ for directory in [
2929
+ role_tasks_dir,
2930
+ role_templates_dir,
2931
+ role_vars_dir,
2932
+ role_defaults_dir,
2933
+ ]:
2934
+ directory.mkdir(parents=True, exist_ok=True)
2935
+
2936
+ return role_dir
2937
+
2938
+
2939
+ def _convert_recipes(
2940
+ cookbook_dir: Path, role_dir: Path, conversion_summary: dict
2941
+ ) -> None:
2942
+ """Convert Chef recipes to Ansible tasks."""
2943
+ recipes_dir = cookbook_dir / "recipes"
2944
+ role_tasks_dir = role_dir / "tasks"
2945
+
2946
+ if not recipes_dir.exists():
2947
+ conversion_summary["warnings"].append(
2948
+ f"No recipes directory found in {cookbook_dir.name}. "
2949
+ "Cookbook cannot be converted to Ansible tasks."
2950
+ )
2951
+ return
2952
+
2953
+ from souschef.converters.playbook import generate_playbook_from_recipe
2954
+
2955
+ recipe_files = list(recipes_dir.glob("*.rb"))
2956
+ if not recipe_files:
2957
+ conversion_summary["warnings"].append(
2958
+ f"No recipe files (*.rb) found in {cookbook_dir.name}/recipes/. "
2959
+ "Cookbook has no recipes to convert."
2960
+ )
2961
+ return
2962
+
2963
+ for recipe_file in recipe_files:
2964
+ try:
2965
+ recipe_name = recipe_file.stem
2966
+
2967
+ # Parse recipe to validate it can be processed
2968
+ parse_result = _parse_recipe(str(recipe_file))
2969
+ if parse_result.startswith("Error:"):
2970
+ conversion_summary["errors"].append(
2971
+ f"Failed to parse recipe {recipe_name}: {parse_result}"
2972
+ )
2973
+ continue
2974
+
2975
+ # Convert to Ansible tasks
2976
+ playbook_yaml = generate_playbook_from_recipe(str(recipe_file))
2977
+
2978
+ # Write as task file (paths normalized at function entry)
2979
+ task_file = (
2980
+ role_tasks_dir / f"{recipe_name}.yml"
2981
+ ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
2982
+ task_file.write_text(playbook_yaml)
2983
+
2984
+ conversion_summary["converted_files"].append(
2985
+ {
2986
+ "type": "task",
2987
+ "source": f"recipes/{recipe_name}.rb",
2988
+ "target": f"{role_dir.name}/tasks/{recipe_name}.yml",
2989
+ }
2990
+ )
2991
+
2992
+ except Exception as e:
2993
+ conversion_summary["errors"].append(
2994
+ f"Error converting recipe {recipe_file.name}: {str(e)}"
2995
+ )
2996
+
2997
+
2998
+ def _convert_templates(
2999
+ cookbook_dir: Path, role_dir: Path, conversion_summary: dict
3000
+ ) -> None:
3001
+ """Convert ERB templates to Jinja2 templates."""
3002
+ templates_dir = cookbook_dir / "templates"
3003
+ role_templates_dir = role_dir / "templates"
3004
+
3005
+ if not templates_dir.exists():
3006
+ return
3007
+
3008
+ for template_file in templates_dir.rglob("*.erb"):
3009
+ try:
3010
+ # Convert ERB to Jinja2
3011
+ conversion_result = _parse_template(str(template_file))
3012
+ if conversion_result.startswith("Error:"):
3013
+ conversion_summary["errors"].append(
3014
+ f"Failed to convert template {template_file.name}: {conversion_result}"
3015
+ )
3016
+ continue
3017
+
3018
+ # Parse the JSON result
3019
+ try:
3020
+ template_data = json.loads(conversion_result)
3021
+ jinja2_content = template_data.get("jinja2_template", "")
3022
+
3023
+ # Determine relative path for role templates (paths normalized at function entry)
3024
+ rel_path = template_file.relative_to(
3025
+ templates_dir
3026
+ ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
3027
+ target_file = (
3028
+ role_templates_dir / rel_path.with_suffix("")
3029
+ ) # Remove .erb extension # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
3030
+ target_file.parent.mkdir(parents=True, exist_ok=True)
3031
+ target_file.write_text(jinja2_content)
3032
+
3033
+ conversion_summary["converted_files"].append(
3034
+ {
3035
+ "type": "template",
3036
+ "source": f"templates/{rel_path}",
3037
+ "target": f"{role_dir.name}/templates/{rel_path.with_suffix('')}",
3038
+ }
3039
+ )
3040
+
3041
+ except json.JSONDecodeError:
3042
+ conversion_summary["errors"].append(
3043
+ f"Invalid JSON result for template {template_file.name}"
3044
+ )
3045
+
3046
+ except Exception as e:
3047
+ conversion_summary["errors"].append(
3048
+ f"Error converting template {template_file.name}: {str(e)}"
3049
+ )
3050
+
3051
+
3052
+ def _convert_attributes(
3053
+ cookbook_dir: Path, role_dir: Path, conversion_summary: dict
3054
+ ) -> None:
3055
+ """Convert Chef attributes to Ansible variables."""
3056
+ import yaml
3057
+
3058
+ attributes_dir = cookbook_dir / "attributes"
3059
+ role_defaults_dir = role_dir / "defaults"
3060
+
3061
+ if not attributes_dir.exists():
3062
+ return
3063
+
3064
+ for attr_file in attributes_dir.glob("*.rb"):
3065
+ try:
3066
+ # Read the file content
3067
+ content = attr_file.read_text()
3068
+
3069
+ # Extract attributes using internal function
3070
+ from souschef.parsers.attributes import (
3071
+ _extract_attributes,
3072
+ _resolve_attribute_precedence,
3073
+ )
3074
+
3075
+ raw_attributes = _extract_attributes(content)
3076
+
3077
+ if not raw_attributes:
3078
+ conversion_summary["warnings"].append(
3079
+ f"No attributes found in {attr_file.name}"
3080
+ )
3081
+ continue
3082
+
3083
+ # Resolve precedence to get final values
3084
+ resolved_attributes = _resolve_attribute_precedence(raw_attributes)
3085
+
3086
+ # Convert to Ansible variable format (flatten nested keys)
3087
+ ansible_vars = {}
3088
+ for attr_path, attr_info in resolved_attributes.items():
3089
+ # Convert Chef attribute paths to Ansible variable names
3090
+ # e.g., "nginx.port" becomes "nginx_port"
3091
+ ansible_key = attr_path.replace(".", "_")
3092
+ ansible_vars[ansible_key] = attr_info["value"]
3093
+
3094
+ # Write as defaults (paths normalized at function entry)
3095
+ defaults_file = (
3096
+ role_defaults_dir / f"{attr_file.stem}.yml"
3097
+ ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
3098
+ defaults_yaml = yaml.dump(ansible_vars, default_flow_style=False, indent=2)
3099
+ defaults_file.write_text(defaults_yaml)
3100
+
3101
+ conversion_summary["converted_files"].append(
3102
+ {
3103
+ "type": "defaults",
3104
+ "source": f"attributes/{attr_file.name}",
3105
+ "target": f"{role_dir.name}/defaults/{attr_file.stem}.yml",
3106
+ }
3107
+ )
3108
+
3109
+ except Exception as e:
3110
+ conversion_summary["errors"].append(
3111
+ f"Error converting attributes {attr_file.name}: {str(e)}"
3112
+ )
3113
+
3114
+
3115
+ def _create_main_task_file(
3116
+ cookbook_dir: Path, role_dir: Path, conversion_summary: dict, include_recipes: bool
3117
+ ) -> None:
3118
+ """Create main.yml task file from default recipe if it exists."""
3119
+ if not include_recipes:
3120
+ return
3121
+
3122
+ default_task_file = role_dir / "tasks" / "main.yml"
3123
+ if default_task_file.exists():
3124
+ return # Already exists
3125
+
3126
+ default_recipe = cookbook_dir / "recipes" / "default.rb"
3127
+ if not default_recipe.exists():
3128
+ return
3129
+
3130
+ try:
3131
+ from souschef.converters.playbook import generate_playbook_from_recipe
3132
+
3133
+ playbook_yaml = generate_playbook_from_recipe(str(default_recipe))
3134
+ default_task_file.write_text(playbook_yaml)
3135
+ conversion_summary["converted_files"].append(
3136
+ {
3137
+ "type": "task",
3138
+ "source": "recipes/default.rb",
3139
+ "target": f"{role_dir.name}/tasks/main.yml",
3140
+ }
3141
+ )
3142
+ except Exception as e:
3143
+ conversion_summary["warnings"].append(
3144
+ f"Could not create main.yml from default recipe: {str(e)}"
3145
+ )
3146
+
3147
+
3148
+ def _create_role_metadata(
3149
+ role_dir: Path,
3150
+ role_name: str,
3151
+ cookbook_name: str,
3152
+ assessment: dict,
3153
+ conversion_summary: dict,
3154
+ ) -> None:
3155
+ """Create Ansible role metadata file."""
3156
+ import yaml
3157
+
3158
+ # role_dir created from normalized paths
3159
+ meta_dir = role_dir / "meta"
3160
+ meta_dir.mkdir(exist_ok=True)
3161
+ meta_file = (
3162
+ meta_dir / "main.yml"
3163
+ ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
3164
+
3165
+ meta_content: dict[str, Any] = {
3166
+ "galaxy_info": {
3167
+ "role_name": role_name,
3168
+ "author": "SousChef Migration",
3169
+ "description": f"Converted from Chef cookbook {cookbook_name}",
3170
+ "license": "MIT",
3171
+ "min_ansible_version": "2.9",
3172
+ "platforms": ["ubuntu", "centos", "redhat"],
3173
+ "galaxy_tags": ["chef", "migration", "converted"],
3174
+ },
3175
+ "dependencies": [],
3176
+ }
3177
+
3178
+ # Add dependencies from assessment data if available
3179
+ if assessment and "dependencies" in assessment:
3180
+ deps = assessment["dependencies"]
3181
+ if isinstance(deps, list):
3182
+ meta_content["dependencies"] = [{"role": dep} for dep in deps]
3183
+
3184
+ meta_yaml = yaml.dump(meta_content, default_flow_style=False, indent=2)
3185
+ meta_file.write_text(meta_yaml)
3186
+
3187
+ conversion_summary["converted_files"].append(
3188
+ {
3189
+ "type": "meta",
3190
+ "source": METADATA_RB,
3191
+ "target": f"{role_name}/meta/main.yml",
3192
+ }
3193
+ )
3194
+
3195
+
3196
+ def _generate_conversion_report(conversion_summary: dict, role_dir: Path) -> str:
3197
+ """Generate a comprehensive conversion report."""
3198
+ summary_lines = [
3199
+ f"# Cookbook Conversion Summary: {conversion_summary['cookbook_name']} → {conversion_summary['role_name']}",
3200
+ "",
3201
+ "## Files Converted:",
3202
+ ]
3203
+
3204
+ for file_info in conversion_summary["converted_files"]:
3205
+ summary_lines.append(
3206
+ f"- {file_info['type'].title()}: {file_info['source']} → {file_info['target']}"
3207
+ )
3208
+
3209
+ if conversion_summary["errors"]:
3210
+ summary_lines.append("")
3211
+ summary_lines.append("## Errors:")
3212
+ for error in conversion_summary["errors"]:
3213
+ summary_lines.append(f"- ❌ {error}")
3214
+
3215
+ if conversion_summary["warnings"]:
3216
+ summary_lines.append("")
3217
+ summary_lines.append("## Warnings:")
3218
+ for warning in conversion_summary["warnings"]:
3219
+ summary_lines.append(f"- ⚠️ {warning}")
3220
+
3221
+ summary_lines.append("")
3222
+ summary_lines.append(f"## Role Location: {role_dir}")
3223
+ summary_lines.append("")
3224
+ summary_lines.append("## Next Steps:")
3225
+ summary_lines.append("1. Review converted files for accuracy")
3226
+ summary_lines.append("2. Test the role with ansible-playbook --check")
3227
+ summary_lines.append("3. Update variable references and dependencies")
3228
+ summary_lines.append("4. Run integration tests")
3229
+
3230
+ return "\n".join(summary_lines)
3231
+
3232
+
3233
+ @mcp.tool()
3234
+ def convert_all_cookbooks_comprehensive(
3235
+ cookbooks_path: str,
3236
+ output_path: str,
3237
+ assessment_data: str = "",
3238
+ include_templates: bool = True,
3239
+ include_attributes: bool = True,
3240
+ include_recipes: bool = True,
3241
+ ) -> str:
3242
+ """
3243
+ Convert all Chef cookbooks in a directory to complete Ansible roles.
3244
+
3245
+ This function performs comprehensive conversion of all Chef cookbooks found in the
3246
+ specified directory to Ansible roles, including recipes, templates, attributes,
3247
+ and proper role structure. It can use assessment data to optimize the conversion process.
3248
+
3249
+ Args:
3250
+ cookbooks_path: Path to the directory containing Chef cookbooks
3251
+ output_path: Directory where the Ansible roles will be created
3252
+ assessment_data: Optional JSON string with assessment results for optimization
3253
+ include_templates: Whether to convert ERB templates to Jinja2 (default: True)
3254
+ include_attributes: Whether to convert attributes to Ansible variables (default: True)
3255
+ include_recipes: Whether to convert recipes to Ansible tasks (default: True)
3256
+
3257
+ Returns:
3258
+ Summary of the conversion process and created files for all cookbooks
3259
+
3260
+ """
3261
+ try:
3262
+ cookbooks_dir, output_dir = _validate_conversion_paths(
3263
+ cookbooks_path, output_path
3264
+ )
3265
+ assessment = _parse_assessment_data(assessment_data)
3266
+ cookbook_dirs = _find_cookbook_directories(cookbooks_dir)
3267
+
3268
+ if not cookbook_dirs:
3269
+ return f"Error: No Chef cookbooks found in {cookbooks_path}. Cookbooks must contain a {METADATA_RB} file."
3270
+
3271
+ overall_summary = _initialize_conversion_summary(len(cookbook_dirs))
3272
+ _convert_all_cookbooks(
3273
+ cookbook_dirs,
3274
+ output_dir,
3275
+ assessment,
3276
+ include_templates,
3277
+ include_attributes,
3278
+ include_recipes,
3279
+ overall_summary,
3280
+ )
3281
+
3282
+ return _generate_batch_conversion_report(overall_summary, output_dir)
3283
+
3284
+ except Exception as e:
3285
+ return format_error_with_context(
3286
+ e, "converting all cookbooks comprehensively", cookbooks_path
3287
+ )
3288
+
3289
+
3290
+ def _validate_conversion_paths(
3291
+ cookbooks_path: str, output_path: str
3292
+ ) -> tuple[Path, Path]:
3293
+ """Validate and return Path objects for conversion paths."""
3294
+ from souschef.core.path_utils import _normalize_path
3295
+
3296
+ cookbooks_dir = _normalize_path(cookbooks_path)
3297
+ output_dir = _normalize_path(output_path)
3298
+
3299
+ if not cookbooks_dir.exists():
3300
+ raise ValueError(f"Cookbooks path does not exist: {cookbooks_path}")
3301
+
3302
+ return cookbooks_dir, output_dir
3303
+
3304
+
3305
+ def _parse_assessment_data(assessment_data: str) -> dict[Any, Any]:
3306
+ """Parse assessment data JSON if provided."""
3307
+ if not assessment_data:
3308
+ return {}
3309
+
3310
+ try:
3311
+ parsed = json.loads(assessment_data)
3312
+ return parsed if isinstance(parsed, dict) else {}
3313
+ except json.JSONDecodeError as e:
3314
+ raise ValueError(f"Invalid assessment data JSON: {assessment_data}") from e
3315
+
3316
+
3317
+ def _find_cookbook_directories(cookbooks_dir: Path) -> list[Path]:
3318
+ """Find all cookbook directories containing metadata.rb files."""
3319
+ return [
3320
+ item
3321
+ for item in cookbooks_dir.iterdir()
3322
+ if item.is_dir() and (item / METADATA_RB).exists()
3323
+ ]
3324
+
3325
+
3326
+ def _initialize_conversion_summary(total_cookbooks: int) -> dict:
3327
+ """Initialize the overall conversion summary structure."""
3328
+ return {
3329
+ "total_cookbooks": total_cookbooks,
3330
+ "converted_cookbooks": [],
3331
+ "failed_cookbooks": [],
3332
+ "total_converted_files": 0,
3333
+ "total_errors": 0,
3334
+ "total_warnings": 0,
3335
+ }
3336
+
3337
+
3338
+ def _convert_all_cookbooks(
3339
+ cookbook_dirs: list[Path],
3340
+ output_dir: Path,
3341
+ assessment: dict,
3342
+ include_templates: bool,
3343
+ include_attributes: bool,
3344
+ include_recipes: bool,
3345
+ overall_summary: dict,
3346
+ ) -> None:
3347
+ """Convert all cookbooks and update the overall summary."""
3348
+ for cookbook_dir in cookbook_dirs:
3349
+ try:
3350
+ _convert_single_cookbook_comprehensive(
3351
+ cookbook_dir,
3352
+ output_dir,
3353
+ assessment,
3354
+ include_templates,
3355
+ include_attributes,
3356
+ include_recipes,
3357
+ overall_summary,
3358
+ )
3359
+ except Exception as e:
3360
+ overall_summary["failed_cookbooks"].append(
3361
+ {
3362
+ "cookbook_name": cookbook_dir.name,
3363
+ "error": str(e),
3364
+ }
3365
+ )
3366
+
3367
+
3368
+ def _convert_single_cookbook_comprehensive(
3369
+ cookbook_dir: Path,
3370
+ output_dir: Path,
3371
+ assessment: dict,
3372
+ include_templates: bool,
3373
+ include_attributes: bool,
3374
+ include_recipes: bool,
3375
+ overall_summary: dict,
3376
+ ) -> None:
3377
+ """Convert a single cookbook comprehensively."""
3378
+ cookbook_name = cookbook_dir.name
3379
+
3380
+ # Get role name from metadata or directory name
3381
+ role_name = _get_role_name(cookbook_dir, cookbook_name)
3382
+
3383
+ # Create role directory structure
3384
+ role_dir = _create_role_structure(output_dir, role_name)
3385
+
3386
+ # Initialize conversion summary for this cookbook
3387
+ conversion_summary = {
3388
+ "cookbook_name": cookbook_name,
3389
+ "role_name": role_name,
3390
+ "converted_files": [],
3391
+ "errors": [],
3392
+ "warnings": [],
3393
+ }
3394
+
3395
+ # Convert components
3396
+ if include_recipes:
3397
+ _convert_recipes(cookbook_dir, role_dir, conversion_summary)
3398
+
3399
+ if include_templates:
3400
+ _convert_templates(cookbook_dir, role_dir, conversion_summary)
3401
+
3402
+ if include_attributes:
3403
+ _convert_attributes(cookbook_dir, role_dir, conversion_summary)
3404
+
3405
+ # Create main task file and metadata
3406
+ _create_main_task_file(cookbook_dir, role_dir, conversion_summary, include_recipes)
3407
+ _create_role_metadata(
3408
+ role_dir, role_name, cookbook_name, assessment, conversion_summary
3409
+ )
3410
+
3411
+ # Add to overall summary
3412
+ overall_summary["converted_cookbooks"].append(
3413
+ {
3414
+ "cookbook_name": cookbook_name,
3415
+ "role_name": role_name,
3416
+ "role_path": str(role_dir),
3417
+ "converted_files": len(conversion_summary["converted_files"]),
3418
+ "errors": len(conversion_summary["errors"]),
3419
+ "warnings": len(conversion_summary["warnings"]),
3420
+ }
3421
+ )
3422
+
3423
+ overall_summary["total_converted_files"] += len(
3424
+ conversion_summary["converted_files"]
3425
+ )
3426
+ overall_summary["total_errors"] += len(conversion_summary["errors"])
3427
+ overall_summary["total_warnings"] += len(conversion_summary["warnings"])
3428
+
3429
+
3430
+ def _get_role_name(cookbook_dir: Path, default_name: str) -> str:
3431
+ """Get the role name from metadata or return default."""
3432
+ metadata_file = cookbook_dir / METADATA_RB
3433
+ if metadata_file.exists():
3434
+ metadata = _parse_cookbook_metadata(str(metadata_file))
3435
+ name = metadata.get("name")
3436
+ # Ensure we return a string, handling potential list values
3437
+ if name is None:
3438
+ return default_name
3439
+ if isinstance(name, list):
3440
+ return name[0] if name else default_name
3441
+ return str(name)
3442
+ return default_name
3443
+
3444
+
3445
+ def _generate_batch_conversion_report(overall_summary: dict, output_dir: Path) -> str:
3446
+ """Generate a comprehensive batch conversion report."""
3447
+ summary_lines = [
3448
+ "# Batch Cookbook Conversion Summary",
3449
+ "",
3450
+ "## Overview:",
3451
+ f"- Total cookbooks found: {overall_summary['total_cookbooks']}",
3452
+ f"- Successfully converted: {len(overall_summary['converted_cookbooks'])}",
3453
+ f"- Failed conversions: {len(overall_summary['failed_cookbooks'])}",
3454
+ f"- Total files converted: {overall_summary['total_converted_files']}",
3455
+ f"- Total errors: {overall_summary['total_errors']}",
3456
+ f"- Total warnings: {overall_summary['total_warnings']}",
3457
+ "",
3458
+ ]
3459
+
3460
+ if overall_summary["converted_cookbooks"]:
3461
+ summary_lines.append("## Successfully Converted Cookbooks:")
3462
+ for cookbook in overall_summary["converted_cookbooks"]:
3463
+ summary_lines.append(
3464
+ f"- **{cookbook['cookbook_name']}** → `{cookbook['role_name']}`"
3465
+ )
3466
+ summary_lines.append(f" - Files converted: {cookbook['converted_files']}")
3467
+ summary_lines.append(f" - Errors: {cookbook['errors']}")
3468
+ summary_lines.append(f" - Warnings: {cookbook['warnings']}")
3469
+ summary_lines.append(f" - Location: {cookbook['role_path']}")
3470
+ summary_lines.append("")
3471
+
3472
+ if overall_summary["failed_cookbooks"]:
3473
+ summary_lines.append("## Failed Conversions:")
3474
+ for failed in overall_summary["failed_cookbooks"]:
3475
+ summary_lines.append(
3476
+ f"- ❌ **{failed['cookbook_name']}**: {failed['error']}"
3477
+ )
3478
+ summary_lines.append("")
3479
+
3480
+ summary_lines.append(f"## Output Directory: {output_dir}")
3481
+ summary_lines.append("")
3482
+ summary_lines.append("## Next Steps:")
3483
+ summary_lines.append("1. Review converted roles for accuracy")
3484
+ summary_lines.append("2. Test roles with ansible-playbook --check")
3485
+ summary_lines.append("3. Update variable references and cross-role dependencies")
3486
+ summary_lines.append("4. Run integration tests across all converted roles")
3487
+ summary_lines.append("5. Consider using Ansible Galaxy for role distribution")
3488
+
3489
+ return "\n".join(summary_lines)
3490
+
3491
+
2814
3492
  # AWX/AAP deployment wrappers for backward compatibility
2815
3493
  def main() -> None:
2816
3494
  """