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/converters/playbook.py
CHANGED
|
@@ -98,6 +98,7 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
98
98
|
max_tokens: int = 4000,
|
|
99
99
|
project_id: str = "",
|
|
100
100
|
base_url: str = "",
|
|
101
|
+
project_recommendations: dict | None = None,
|
|
101
102
|
) -> str:
|
|
102
103
|
"""
|
|
103
104
|
Generate an AI-enhanced Ansible playbook from a Chef recipe.
|
|
@@ -116,6 +117,8 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
116
117
|
max_tokens: Maximum tokens to generate.
|
|
117
118
|
project_id: Project ID for IBM Watsonx (required for watson provider).
|
|
118
119
|
base_url: Custom base URL for the AI provider.
|
|
120
|
+
project_recommendations: Dictionary containing project-level analysis
|
|
121
|
+
and recommendations from cookbook assessment.
|
|
119
122
|
|
|
120
123
|
Returns:
|
|
121
124
|
AI-generated Ansible playbook in YAML format.
|
|
@@ -146,6 +149,7 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
146
149
|
max_tokens,
|
|
147
150
|
project_id,
|
|
148
151
|
base_url,
|
|
152
|
+
project_recommendations,
|
|
149
153
|
)
|
|
150
154
|
|
|
151
155
|
return ai_playbook
|
|
@@ -165,6 +169,7 @@ def _generate_playbook_with_ai(
|
|
|
165
169
|
max_tokens: int,
|
|
166
170
|
project_id: str = "",
|
|
167
171
|
base_url: str = "",
|
|
172
|
+
project_recommendations: dict | None = None,
|
|
168
173
|
) -> str:
|
|
169
174
|
"""Generate Ansible playbook using AI for intelligent conversion."""
|
|
170
175
|
try:
|
|
@@ -174,7 +179,9 @@ def _generate_playbook_with_ai(
|
|
|
174
179
|
return client
|
|
175
180
|
|
|
176
181
|
# Create the AI prompt
|
|
177
|
-
prompt = _create_ai_conversion_prompt(
|
|
182
|
+
prompt = _create_ai_conversion_prompt(
|
|
183
|
+
raw_content, parsed_content, recipe_name, project_recommendations
|
|
184
|
+
)
|
|
178
185
|
|
|
179
186
|
# Call the AI API and get response
|
|
180
187
|
ai_response = _call_ai_api(
|
|
@@ -333,96 +340,257 @@ def _call_ai_api(
|
|
|
333
340
|
|
|
334
341
|
|
|
335
342
|
def _create_ai_conversion_prompt(
|
|
336
|
-
raw_content: str,
|
|
343
|
+
raw_content: str,
|
|
344
|
+
parsed_content: str,
|
|
345
|
+
recipe_name: str,
|
|
346
|
+
project_recommendations: dict | None = None,
|
|
337
347
|
) -> str:
|
|
338
348
|
"""Create a comprehensive prompt for AI conversion."""
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
349
|
+
prompt_parts = _build_base_prompt_parts(raw_content, parsed_content, recipe_name)
|
|
350
|
+
|
|
351
|
+
# Add project context if available
|
|
352
|
+
if project_recommendations:
|
|
353
|
+
prompt_parts.extend(
|
|
354
|
+
_build_project_context_parts(project_recommendations, recipe_name)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
prompt_parts.extend(_build_conversion_requirements_parts())
|
|
358
|
+
|
|
359
|
+
# Add project-specific guidance if available
|
|
360
|
+
if project_recommendations:
|
|
361
|
+
prompt_parts.extend(_build_project_guidance_parts(project_recommendations))
|
|
362
|
+
|
|
363
|
+
prompt_parts.extend(_build_output_format_parts())
|
|
364
|
+
|
|
365
|
+
return "\n".join(prompt_parts)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _build_base_prompt_parts(
|
|
369
|
+
raw_content: str, parsed_content: str, recipe_name: str
|
|
370
|
+
) -> list[str]:
|
|
371
|
+
"""Build the base prompt parts."""
|
|
372
|
+
return [
|
|
373
|
+
"You are an expert at converting Chef recipes to Ansible playbooks.",
|
|
374
|
+
"Your task is to convert the following Chef recipe into a high-quality,",
|
|
375
|
+
"production-ready Ansible playbook.",
|
|
376
|
+
"",
|
|
377
|
+
"CHEF RECIPE CONTENT:",
|
|
378
|
+
raw_content,
|
|
379
|
+
"",
|
|
380
|
+
"PARSED RECIPE ANALYSIS:",
|
|
381
|
+
parsed_content,
|
|
382
|
+
"",
|
|
383
|
+
f"RECIPE NAME: {recipe_name}",
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _build_project_context_parts(
|
|
388
|
+
project_recommendations: dict, recipe_name: str
|
|
389
|
+
) -> list[str]:
|
|
390
|
+
"""Build project context parts."""
|
|
391
|
+
# Extract values to shorten f-strings
|
|
392
|
+
complexity = project_recommendations.get("project_complexity", "Unknown")
|
|
393
|
+
strategy = project_recommendations.get("migration_strategy", "Unknown")
|
|
394
|
+
effort_days = project_recommendations.get("project_effort_days", 0)
|
|
395
|
+
density = project_recommendations.get("dependency_density", 0)
|
|
396
|
+
|
|
397
|
+
parts = [
|
|
398
|
+
"",
|
|
399
|
+
"PROJECT CONTEXT:",
|
|
400
|
+
f"Project Complexity: {complexity}",
|
|
401
|
+
f"Migration Strategy: {strategy}",
|
|
402
|
+
f"Total Project Effort: {effort_days:.1f} days",
|
|
403
|
+
f"Dependency Density: {density:.2f}",
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
# Add migration recommendations
|
|
407
|
+
recommendations = project_recommendations.get("recommendations", [])
|
|
408
|
+
if recommendations:
|
|
409
|
+
parts.extend(
|
|
410
|
+
[
|
|
411
|
+
"",
|
|
412
|
+
"PROJECT MIGRATION RECOMMENDATIONS:",
|
|
413
|
+
]
|
|
414
|
+
)
|
|
415
|
+
for rec in recommendations[:5]: # Limit to first 5 recommendations
|
|
416
|
+
parts.append(f"- {rec}")
|
|
417
|
+
|
|
418
|
+
# Add dependency information
|
|
419
|
+
migration_order = project_recommendations.get("migration_order", [])
|
|
420
|
+
if migration_order:
|
|
421
|
+
recipe_position = _find_recipe_position_in_migration_order(
|
|
422
|
+
migration_order, recipe_name
|
|
423
|
+
)
|
|
424
|
+
if recipe_position:
|
|
425
|
+
dependencies = ", ".join(recipe_position.get("dependencies", [])) or "None"
|
|
426
|
+
parts.extend(
|
|
427
|
+
[
|
|
428
|
+
"",
|
|
429
|
+
"MIGRATION CONTEXT FOR THIS RECIPE:",
|
|
430
|
+
f"Phase: {recipe_position.get('phase', 'Unknown')}",
|
|
431
|
+
f"Complexity: {recipe_position.get('complexity', 'Unknown')}",
|
|
432
|
+
f"Dependencies: {dependencies}",
|
|
433
|
+
f"Migration Reason: {recipe_position.get('reason', 'Unknown')}",
|
|
434
|
+
]
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return parts
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _find_recipe_position_in_migration_order(
|
|
441
|
+
migration_order: list[dict[str, Any]], recipe_name: str
|
|
442
|
+
) -> dict[str, Any] | None:
|
|
443
|
+
"""Find this recipe's position in migration order."""
|
|
444
|
+
for item in migration_order:
|
|
445
|
+
cookbook_name = recipe_name.replace(".rb", "").replace("recipes/", "")
|
|
446
|
+
if item.get("cookbook") == cookbook_name:
|
|
447
|
+
return item
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _build_conversion_requirements_parts() -> list[str]:
|
|
452
|
+
"""Build conversion requirements parts."""
|
|
453
|
+
return [
|
|
454
|
+
"",
|
|
455
|
+
"CONVERSION REQUIREMENTS:",
|
|
456
|
+
"",
|
|
457
|
+
"1. **Understand the Intent**: Analyze what this Chef recipe is trying to",
|
|
458
|
+
" accomplish. Look at the resources, their properties, and the overall",
|
|
459
|
+
" workflow.",
|
|
460
|
+
"",
|
|
461
|
+
"2. **Best Practices**: Generate Ansible code that follows Ansible best",
|
|
462
|
+
" practices:",
|
|
463
|
+
" - Use appropriate modules (ansible.builtin.* when possible)",
|
|
464
|
+
" - Include proper error handling and idempotency",
|
|
465
|
+
" - Use meaningful variable names",
|
|
466
|
+
" - Include comments explaining complex logic",
|
|
467
|
+
" - Handle edge cases and failure scenarios",
|
|
468
|
+
"",
|
|
469
|
+
"3. **Resource Mapping**: Convert Chef resources to appropriate Ansible",
|
|
470
|
+
" modules:",
|
|
471
|
+
" - package → ansible.builtin.package or specific package managers",
|
|
472
|
+
" - service → ansible.builtin.service",
|
|
473
|
+
" - file/directory → ansible.builtin.file",
|
|
474
|
+
" - template → ansible.builtin.template (CHANGE .erb to .j2 extension)",
|
|
475
|
+
" - execute → ansible.builtin.command/shell",
|
|
476
|
+
" - user/group → ansible.builtin.user/group",
|
|
477
|
+
" - mount → ansible.builtin.mount",
|
|
478
|
+
" - include_recipe → ansible.builtin.include_role (for unknown cookbooks)",
|
|
479
|
+
" - include_recipe → specific role imports (for known cookbooks)",
|
|
480
|
+
"",
|
|
481
|
+
"4. **Template Conversions**: CRITICAL for template resources:",
|
|
482
|
+
" - Change file extension from .erb to .j2 in the 'src' parameter",
|
|
483
|
+
" - Example: 'config.erb' becomes 'config.j2'",
|
|
484
|
+
" - Add note: Templates need manual ERB→Jinja2 conversion",
|
|
485
|
+
" - ERB syntax: <%= variable %> → Jinja2: {{ variable }}",
|
|
486
|
+
" - ERB blocks: <% code %> → Jinja2: {% code %}",
|
|
487
|
+
"",
|
|
488
|
+
"5. **Chef Data Bags to Ansible Vault**: Convert data bag lookups:",
|
|
489
|
+
" - Chef::EncryptedDataBagItem.load('bag', 'item') →",
|
|
490
|
+
" - Ansible: Use group_vars/ or host_vars/ with ansible-vault",
|
|
491
|
+
" - Store sensitive data in encrypted YAML files, not inline lookups",
|
|
492
|
+
" - Example: Define ssh_key in group_vars/all/vault.yml (encrypted)",
|
|
493
|
+
"",
|
|
494
|
+
"6. **Variables and Facts**: Convert Chef node attributes to Ansible",
|
|
495
|
+
" variables/facts appropriately:",
|
|
496
|
+
" - node['attribute'] → {{ ansible_facts.attribute }} or vars",
|
|
497
|
+
" - node['platform'] → {{ ansible_distribution }}",
|
|
498
|
+
" - node['platform_version'] → {{ ansible_distribution_version }}",
|
|
499
|
+
"",
|
|
500
|
+
"7. **Conditionals**: Convert Chef guards (only_if/not_if) to Ansible when",
|
|
501
|
+
" conditions.",
|
|
502
|
+
"",
|
|
503
|
+
"8. **Notifications**: Convert Chef notifications to Ansible handlers",
|
|
504
|
+
" where appropriate.",
|
|
505
|
+
"",
|
|
506
|
+
"9. **Idempotency**: Ensure the playbook is idempotent and can be run",
|
|
507
|
+
" multiple times safely.",
|
|
508
|
+
"",
|
|
509
|
+
"10. **Error Handling**: Include proper error handling and rollback",
|
|
510
|
+
" considerations.",
|
|
511
|
+
"",
|
|
512
|
+
"11. **Task Ordering**: CRITICAL: Ensure tasks are ordered logically.",
|
|
513
|
+
" - Install packages BEFORE configuring them.",
|
|
514
|
+
" - create users/groups BEFORE using them in file permissions.",
|
|
515
|
+
" - Place configuration files BEFORE starting/restarting services.",
|
|
516
|
+
" - Ensure directories exist BEFORE creating files in them.",
|
|
517
|
+
"",
|
|
518
|
+
"12. **Handlers**: Verify that all notified handlers are actually defined",
|
|
519
|
+
" in the handlers section.",
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _build_project_guidance_parts(project_recommendations: dict) -> list[str]:
|
|
524
|
+
"""Build project-specific guidance parts."""
|
|
525
|
+
strategy = project_recommendations.get("migration_strategy", "").lower()
|
|
526
|
+
parts = []
|
|
527
|
+
|
|
528
|
+
if "parallel" in strategy:
|
|
529
|
+
parallel_tracks = project_recommendations.get("parallel_tracks", 2)
|
|
530
|
+
parts.extend(
|
|
531
|
+
[
|
|
532
|
+
"",
|
|
533
|
+
"11. **Parallel Migration Context**: This recipe is part of a",
|
|
534
|
+
f" parallel migration with {parallel_tracks} tracks.",
|
|
535
|
+
" Ensure this playbook can run independently without",
|
|
536
|
+
" dependencies on other cookbooks in the project.",
|
|
537
|
+
]
|
|
538
|
+
)
|
|
539
|
+
elif "phased" in strategy:
|
|
540
|
+
parts.extend(
|
|
541
|
+
[
|
|
542
|
+
"",
|
|
543
|
+
"11. **Phased Migration Context**: This recipe is part of a phased",
|
|
544
|
+
" migration approach. Consider dependencies and ensure proper",
|
|
545
|
+
" ordering within the broader project migration plan.",
|
|
546
|
+
]
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return parts
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _build_output_format_parts() -> list[str]:
|
|
553
|
+
"""Build output format parts."""
|
|
554
|
+
return [
|
|
555
|
+
"",
|
|
556
|
+
"OUTPUT FORMAT:",
|
|
557
|
+
"Return ONLY a valid YAML Ansible playbook. Do not include any",
|
|
558
|
+
" explanation, markdown formatting, or code blocks. The output should",
|
|
559
|
+
" be pure YAML that can be directly used as an Ansible playbook.",
|
|
560
|
+
"",
|
|
561
|
+
"CRITICAL YAML SYNTAX RULES:",
|
|
562
|
+
"- Use block mapping style (one key per line) NOT flow mapping style",
|
|
563
|
+
"- NEVER use { } for mappings with conditionals or complex expressions",
|
|
564
|
+
"- Correct: Use multi-line format:",
|
|
565
|
+
" - name: Include role conditionally",
|
|
566
|
+
" ansible.builtin.import_role:",
|
|
567
|
+
" name: my_role",
|
|
568
|
+
" when: condition_here",
|
|
569
|
+
"- WRONG: { role: my_role, when: condition } # This is INVALID",
|
|
570
|
+
"",
|
|
571
|
+
"The playbook should include:",
|
|
572
|
+
"- A proper name",
|
|
573
|
+
"- Appropriate hosts (default to 'all')",
|
|
574
|
+
"- Variables section if needed",
|
|
575
|
+
"- Tasks section with all converted resources",
|
|
576
|
+
"- Handlers section if notifications are used",
|
|
577
|
+
"- Any necessary pre_tasks or post_tasks",
|
|
578
|
+
"",
|
|
579
|
+
"Example structure:",
|
|
580
|
+
"---",
|
|
581
|
+
"- name: Convert of <recipe_name>",
|
|
582
|
+
" hosts: all",
|
|
583
|
+
" become: true",
|
|
584
|
+
" vars:",
|
|
585
|
+
" # Variables here",
|
|
586
|
+
" tasks:",
|
|
587
|
+
" # Tasks here",
|
|
588
|
+
" handlers:",
|
|
589
|
+
" # Handlers here",
|
|
590
|
+
"",
|
|
591
|
+
"Focus on creating a functional, well-structured Ansible playbook that",
|
|
592
|
+
"achieves the same outcome as the Chef recipe.",
|
|
593
|
+
]
|
|
426
594
|
|
|
427
595
|
|
|
428
596
|
def _clean_ai_playbook_response(ai_response: str) -> str:
|
|
@@ -507,6 +675,7 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
|
|
|
507
675
|
if shutil.which("ansible-lint") is None:
|
|
508
676
|
return None
|
|
509
677
|
|
|
678
|
+
tmp_path = None
|
|
510
679
|
try:
|
|
511
680
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tmp:
|
|
512
681
|
tmp.write(playbook_content)
|
|
@@ -528,7 +697,7 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
|
|
|
528
697
|
except Exception:
|
|
529
698
|
return None
|
|
530
699
|
finally:
|
|
531
|
-
if
|
|
700
|
+
if tmp_path is not None and Path(tmp_path).exists():
|
|
532
701
|
Path(tmp_path).unlink()
|
|
533
702
|
|
|
534
703
|
|
souschef/converters/resource.py
CHANGED
|
@@ -30,20 +30,14 @@ def _parse_properties(properties_str: str) -> dict[str, Any]:
|
|
|
30
30
|
if not properties_str:
|
|
31
31
|
return {}
|
|
32
32
|
try:
|
|
33
|
-
#
|
|
33
|
+
# Use ast.literal_eval for safe parsing of Python literals
|
|
34
34
|
result = ast.literal_eval(properties_str)
|
|
35
35
|
if isinstance(result, dict):
|
|
36
36
|
return result
|
|
37
37
|
return {}
|
|
38
38
|
except (ValueError, SyntaxError):
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
result = eval(properties_str) # noqa: S307
|
|
42
|
-
if isinstance(result, dict):
|
|
43
|
-
return result
|
|
44
|
-
return {}
|
|
45
|
-
except Exception:
|
|
46
|
-
return {}
|
|
39
|
+
# If parsing fails, return empty dict rather than using unsafe eval
|
|
40
|
+
return {}
|
|
47
41
|
|
|
48
42
|
|
|
49
43
|
def _normalize_template_value(value: Any) -> Any:
|
|
@@ -245,13 +239,17 @@ def _get_include_recipe_params(
|
|
|
245
239
|
Build parameters for include_recipe resources.
|
|
246
240
|
|
|
247
241
|
Uses cookbook-specific configurations when available.
|
|
242
|
+
Falls back to include_role for unknown cookbooks.
|
|
248
243
|
"""
|
|
249
244
|
cookbook_config = get_cookbook_package_config(resource_name)
|
|
250
245
|
if cookbook_config:
|
|
251
246
|
# Return a copy to prevent callers from mutating the shared mapping.
|
|
252
247
|
return dict(cookbook_config["params"])
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
|
|
249
|
+
# For unknown cookbooks, use include_role with the cookbook name
|
|
250
|
+
# Extract cookbook name from "cookbook::recipe" format
|
|
251
|
+
cookbook_name = resource_name.split("::")[0]
|
|
252
|
+
return {"name": cookbook_name}
|
|
255
253
|
|
|
256
254
|
|
|
257
255
|
def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
@@ -303,6 +301,9 @@ def _convert_chef_resource_to_ansible(
|
|
|
303
301
|
cookbook_config = get_cookbook_package_config(resource_name)
|
|
304
302
|
if cookbook_config:
|
|
305
303
|
ansible_module = cookbook_config["module"]
|
|
304
|
+
else:
|
|
305
|
+
# For include_recipe without specific mapping, use include_role
|
|
306
|
+
ansible_module = "ansible.builtin.include_role"
|
|
306
307
|
|
|
307
308
|
# Handle unknown resource types
|
|
308
309
|
if ansible_module is None:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Chef ERB template to Jinja2 converter."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from souschef.parsers.template import (
|
|
6
|
+
_convert_erb_to_jinja2,
|
|
7
|
+
_extract_template_variables,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def convert_template_file(erb_path: str) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
Convert an ERB template file to Jinja2 format.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
erb_path: Path to the ERB template file.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary containing:
|
|
20
|
+
- success: bool, whether conversion succeeded
|
|
21
|
+
- original_file: str, path to original ERB file
|
|
22
|
+
- jinja2_file: str, suggested path for .j2 file
|
|
23
|
+
- jinja2_content: str, converted Jinja2 template content
|
|
24
|
+
- variables: list, variables found in template
|
|
25
|
+
- error: str (optional), error message if conversion failed
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
file_path = Path(erb_path).resolve()
|
|
30
|
+
|
|
31
|
+
if not file_path.exists():
|
|
32
|
+
return {
|
|
33
|
+
"success": False,
|
|
34
|
+
"error": f"File not found: {erb_path}",
|
|
35
|
+
"original_file": erb_path,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
return {
|
|
40
|
+
"success": False,
|
|
41
|
+
"error": f"Path is not a file: {erb_path}",
|
|
42
|
+
"original_file": erb_path,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Read ERB template
|
|
46
|
+
try:
|
|
47
|
+
content = file_path.read_text(encoding="utf-8")
|
|
48
|
+
except UnicodeDecodeError:
|
|
49
|
+
return {
|
|
50
|
+
"success": False,
|
|
51
|
+
"error": f"Unable to decode {erb_path} as UTF-8 text",
|
|
52
|
+
"original_file": str(file_path),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Extract variables
|
|
56
|
+
variables = _extract_template_variables(content)
|
|
57
|
+
|
|
58
|
+
# Convert ERB to Jinja2
|
|
59
|
+
jinja2_content = _convert_erb_to_jinja2(content)
|
|
60
|
+
|
|
61
|
+
# Determine output file name
|
|
62
|
+
jinja2_file = str(file_path).replace(".erb", ".j2")
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"success": True,
|
|
66
|
+
"original_file": str(file_path),
|
|
67
|
+
"jinja2_file": jinja2_file,
|
|
68
|
+
"jinja2_content": jinja2_content,
|
|
69
|
+
"variables": sorted(variables),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {
|
|
74
|
+
"success": False,
|
|
75
|
+
"error": f"Conversion failed: {e}",
|
|
76
|
+
"original_file": erb_path,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def convert_cookbook_templates(cookbook_path: str) -> dict:
|
|
81
|
+
"""
|
|
82
|
+
Convert all ERB templates in a cookbook to Jinja2.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
cookbook_path: Path to the cookbook directory.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary containing:
|
|
89
|
+
- success: bool, whether all conversions succeeded
|
|
90
|
+
- templates_converted: int, number of templates successfully converted
|
|
91
|
+
- templates_failed: int, number of templates that failed conversion
|
|
92
|
+
- results: list of dict, individual template conversion results
|
|
93
|
+
- error: str (optional), error message if cookbook not found
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
cookbook_dir = Path(cookbook_path).resolve()
|
|
98
|
+
|
|
99
|
+
if not cookbook_dir.exists():
|
|
100
|
+
return {
|
|
101
|
+
"success": False,
|
|
102
|
+
"error": f"Cookbook directory not found: {cookbook_path}",
|
|
103
|
+
"templates_converted": 0,
|
|
104
|
+
"templates_failed": 0,
|
|
105
|
+
"results": [],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Find all .erb files in the cookbook
|
|
109
|
+
erb_files = list(cookbook_dir.glob("**/*.erb"))
|
|
110
|
+
|
|
111
|
+
if not erb_files:
|
|
112
|
+
return {
|
|
113
|
+
"success": True,
|
|
114
|
+
"templates_converted": 0,
|
|
115
|
+
"templates_failed": 0,
|
|
116
|
+
"results": [],
|
|
117
|
+
"message": "No ERB templates found in cookbook",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
results = []
|
|
121
|
+
templates_converted = 0
|
|
122
|
+
templates_failed = 0
|
|
123
|
+
|
|
124
|
+
for erb_file in erb_files:
|
|
125
|
+
result = convert_template_file(str(erb_file))
|
|
126
|
+
results.append(result)
|
|
127
|
+
|
|
128
|
+
if result["success"]:
|
|
129
|
+
templates_converted += 1
|
|
130
|
+
else:
|
|
131
|
+
templates_failed += 1
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"success": templates_failed == 0,
|
|
135
|
+
"templates_converted": templates_converted,
|
|
136
|
+
"templates_failed": templates_failed,
|
|
137
|
+
"results": results,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
return {
|
|
142
|
+
"success": False,
|
|
143
|
+
"error": f"Failed to convert cookbook templates: {e}",
|
|
144
|
+
"templates_converted": 0,
|
|
145
|
+
"templates_failed": 0,
|
|
146
|
+
"results": [],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def convert_template_with_ai(erb_path: str, ai_service=None) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
Convert an ERB template to Jinja2 using AI assistance for complex conversions.
|
|
153
|
+
|
|
154
|
+
This function first attempts rule-based conversion, then optionally uses AI
|
|
155
|
+
for validation or complex Ruby logic that can't be automatically converted.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
erb_path: Path to the ERB template file.
|
|
159
|
+
ai_service: Optional AI service instance for complex conversions.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dictionary with conversion results (same format as convert_template_file).
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
# Start with rule-based conversion
|
|
166
|
+
result = convert_template_file(erb_path)
|
|
167
|
+
|
|
168
|
+
# Add conversion method metadata
|
|
169
|
+
result["conversion_method"] = "rule-based"
|
|
170
|
+
|
|
171
|
+
# Future enhancement: Use AI service to validate/improve complex conversions
|
|
172
|
+
if ai_service is not None:
|
|
173
|
+
# AI validation/improvement logic deferred to future enhancement
|
|
174
|
+
# when AI integration becomes more critical to the template conversion process
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return result
|