mcp-souschef 2.8.0__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.
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.0.0.dist-info}/METADATA +82 -10
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.0.0.dist-info}/RECORD +23 -19
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.0.0.dist-info}/WHEEL +1 -1
- souschef/__init__.py +37 -5
- souschef/assessment.py +1248 -57
- souschef/ci/common.py +126 -0
- souschef/ci/github_actions.py +3 -92
- souschef/ci/gitlab_ci.py +2 -52
- souschef/ci/jenkins_pipeline.py +2 -59
- souschef/cli.py +117 -8
- souschef/converters/playbook.py +259 -90
- souschef/converters/resource.py +12 -11
- souschef/converters/template.py +177 -0
- souschef/core/metrics.py +313 -0
- souschef/core/validation.py +53 -0
- souschef/deployment.py +61 -9
- souschef/server.py +680 -0
- souschef/ui/app.py +469 -351
- souschef/ui/pages/ai_settings.py +74 -8
- souschef/ui/pages/cookbook_analysis.py +2467 -298
- souschef/ui/pages/validation_reports.py +274 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.0.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.0.0.dist-info}/licenses/LICENSE +0 -0
souschef/server.py
CHANGED
|
@@ -282,6 +282,9 @@ ERROR_FILE_NOT_FOUND = "Error: File not found at {path}"
|
|
|
282
282
|
ERROR_IS_DIRECTORY = "Error: {path} is a directory, not a file"
|
|
283
283
|
ERROR_PERMISSION_DENIED = "Error: Permission denied for {path}"
|
|
284
284
|
|
|
285
|
+
# File constants
|
|
286
|
+
METADATA_RB = "metadata.rb"
|
|
287
|
+
|
|
285
288
|
# Validation Framework Classes
|
|
286
289
|
|
|
287
290
|
|
|
@@ -2809,6 +2812,683 @@ def parse_chef_migration_assessment(
|
|
|
2809
2812
|
)
|
|
2810
2813
|
|
|
2811
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
|
+
|
|
2812
3492
|
# AWX/AAP deployment wrappers for backward compatibility
|
|
2813
3493
|
def main() -> None:
|
|
2814
3494
|
"""
|