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.
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
  """