mcp-souschef 2.8.0__py3-none-any.whl → 3.2.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.2.0.dist-info}/METADATA +159 -384
- mcp_souschef-3.2.0.dist-info/RECORD +47 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +1 -1
- souschef/__init__.py +31 -7
- souschef/assessment.py +1451 -105
- 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 +149 -16
- souschef/converters/playbook.py +378 -138
- souschef/converters/resource.py +12 -11
- souschef/converters/template.py +177 -0
- souschef/core/__init__.py +6 -1
- souschef/core/metrics.py +313 -0
- souschef/core/path_utils.py +233 -19
- souschef/core/validation.py +53 -0
- souschef/deployment.py +71 -12
- souschef/generators/__init__.py +13 -0
- souschef/generators/repo.py +695 -0
- souschef/parsers/attributes.py +1 -1
- souschef/parsers/habitat.py +1 -1
- souschef/parsers/inspec.py +25 -2
- souschef/parsers/metadata.py +5 -3
- souschef/parsers/recipe.py +1 -1
- souschef/parsers/resource.py +1 -1
- souschef/parsers/template.py +1 -1
- souschef/server.py +1039 -121
- souschef/ui/app.py +486 -374
- souschef/ui/pages/ai_settings.py +74 -8
- souschef/ui/pages/cookbook_analysis.py +3216 -373
- souschef/ui/pages/validation_reports.py +274 -0
- mcp_souschef-2.8.0.dist-info/RECORD +0 -42
- souschef/converters/cookbook_specific.py.backup +0 -109
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
souschef/converters/playbook.py
CHANGED
|
@@ -7,6 +7,7 @@ inventory scripts.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import os
|
|
10
11
|
import re
|
|
11
12
|
import shutil
|
|
12
13
|
import subprocess
|
|
@@ -31,7 +32,13 @@ from souschef.core.constants import (
|
|
|
31
32
|
REGEX_WHITESPACE_QUOTE,
|
|
32
33
|
VALUE_PREFIX,
|
|
33
34
|
)
|
|
34
|
-
from souschef.core.path_utils import
|
|
35
|
+
from souschef.core.path_utils import (
|
|
36
|
+
_normalize_path,
|
|
37
|
+
_safe_join,
|
|
38
|
+
safe_exists,
|
|
39
|
+
safe_glob,
|
|
40
|
+
safe_read_text,
|
|
41
|
+
)
|
|
35
42
|
from souschef.parsers.attributes import parse_attributes
|
|
36
43
|
from souschef.parsers.recipe import parse_recipe
|
|
37
44
|
|
|
@@ -42,9 +49,7 @@ except ImportError:
|
|
|
42
49
|
requests = None
|
|
43
50
|
|
|
44
51
|
try:
|
|
45
|
-
from ibm_watsonx_ai import
|
|
46
|
-
APIClient,
|
|
47
|
-
)
|
|
52
|
+
from ibm_watsonx_ai import APIClient # type: ignore[import-not-found]
|
|
48
53
|
except ImportError:
|
|
49
54
|
APIClient = None
|
|
50
55
|
|
|
@@ -52,12 +57,13 @@ except ImportError:
|
|
|
52
57
|
MAX_GUARD_LENGTH = 500
|
|
53
58
|
|
|
54
59
|
|
|
55
|
-
def generate_playbook_from_recipe(recipe_path: str) -> str:
|
|
60
|
+
def generate_playbook_from_recipe(recipe_path: str, cookbook_path: str = "") -> str:
|
|
56
61
|
"""
|
|
57
62
|
Generate a complete Ansible playbook from a Chef recipe.
|
|
58
63
|
|
|
59
64
|
Args:
|
|
60
65
|
recipe_path: Path to the Chef recipe (.rb) file.
|
|
66
|
+
cookbook_path: Optional path to the cookbook root for path validation.
|
|
61
67
|
|
|
62
68
|
Returns:
|
|
63
69
|
Complete Ansible playbook in YAML format with tasks, handlers, and
|
|
@@ -73,10 +79,18 @@ def generate_playbook_from_recipe(recipe_path: str) -> str:
|
|
|
73
79
|
|
|
74
80
|
# Parse the raw recipe file for advanced features
|
|
75
81
|
recipe_file = _normalize_path(recipe_path)
|
|
76
|
-
if not recipe_file.exists():
|
|
77
|
-
return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
# Validate path if cookbook_path provided
|
|
84
|
+
base_path = (
|
|
85
|
+
Path(cookbook_path).resolve() if cookbook_path else recipe_file.parent
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
if not safe_exists(recipe_file, base_path):
|
|
90
|
+
return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
|
|
91
|
+
raw_content = safe_read_text(recipe_file, base_path)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return f"{ERROR_PREFIX} Path traversal attempt detected: {recipe_path}"
|
|
80
94
|
|
|
81
95
|
# Generate playbook structure
|
|
82
96
|
playbook: str = _generate_playbook_structure(
|
|
@@ -98,6 +112,8 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
98
112
|
max_tokens: int = 4000,
|
|
99
113
|
project_id: str = "",
|
|
100
114
|
base_url: str = "",
|
|
115
|
+
project_recommendations: dict | None = None,
|
|
116
|
+
cookbook_path: str = "",
|
|
101
117
|
) -> str:
|
|
102
118
|
"""
|
|
103
119
|
Generate an AI-enhanced Ansible playbook from a Chef recipe.
|
|
@@ -116,6 +132,9 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
116
132
|
max_tokens: Maximum tokens to generate.
|
|
117
133
|
project_id: Project ID for IBM Watsonx (required for watson provider).
|
|
118
134
|
base_url: Custom base URL for the AI provider.
|
|
135
|
+
project_recommendations: Dictionary containing project-level analysis
|
|
136
|
+
and recommendations from cookbook assessment.
|
|
137
|
+
cookbook_path: Optional path to the cookbook root for path validation.
|
|
119
138
|
|
|
120
139
|
Returns:
|
|
121
140
|
AI-generated Ansible playbook in YAML format.
|
|
@@ -124,10 +143,18 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
124
143
|
try:
|
|
125
144
|
# Parse the recipe file
|
|
126
145
|
recipe_file = _normalize_path(recipe_path)
|
|
127
|
-
if not recipe_file.exists():
|
|
128
|
-
return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
|
|
129
146
|
|
|
130
|
-
|
|
147
|
+
# Validate path if cookbook_path provided
|
|
148
|
+
base_path = (
|
|
149
|
+
Path(cookbook_path).resolve() if cookbook_path else recipe_file.parent
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
if not safe_exists(recipe_file, base_path):
|
|
154
|
+
return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
|
|
155
|
+
raw_content = safe_read_text(recipe_file, base_path)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return f"{ERROR_PREFIX} Path traversal attempt detected: {recipe_path}"
|
|
131
158
|
|
|
132
159
|
# Get basic recipe parsing for context
|
|
133
160
|
parsed_content = parse_recipe(recipe_path)
|
|
@@ -146,6 +173,7 @@ def generate_playbook_from_recipe_with_ai(
|
|
|
146
173
|
max_tokens,
|
|
147
174
|
project_id,
|
|
148
175
|
base_url,
|
|
176
|
+
project_recommendations,
|
|
149
177
|
)
|
|
150
178
|
|
|
151
179
|
return ai_playbook
|
|
@@ -165,6 +193,7 @@ def _generate_playbook_with_ai(
|
|
|
165
193
|
max_tokens: int,
|
|
166
194
|
project_id: str = "",
|
|
167
195
|
base_url: str = "",
|
|
196
|
+
project_recommendations: dict | None = None,
|
|
168
197
|
) -> str:
|
|
169
198
|
"""Generate Ansible playbook using AI for intelligent conversion."""
|
|
170
199
|
try:
|
|
@@ -174,7 +203,9 @@ def _generate_playbook_with_ai(
|
|
|
174
203
|
return client
|
|
175
204
|
|
|
176
205
|
# Create the AI prompt
|
|
177
|
-
prompt = _create_ai_conversion_prompt(
|
|
206
|
+
prompt = _create_ai_conversion_prompt(
|
|
207
|
+
raw_content, parsed_content, recipe_name, project_recommendations
|
|
208
|
+
)
|
|
178
209
|
|
|
179
210
|
# Call the AI API and get response
|
|
180
211
|
ai_response = _call_ai_api(
|
|
@@ -333,96 +364,257 @@ def _call_ai_api(
|
|
|
333
364
|
|
|
334
365
|
|
|
335
366
|
def _create_ai_conversion_prompt(
|
|
336
|
-
raw_content: str,
|
|
367
|
+
raw_content: str,
|
|
368
|
+
parsed_content: str,
|
|
369
|
+
recipe_name: str,
|
|
370
|
+
project_recommendations: dict | None = None,
|
|
337
371
|
) -> str:
|
|
338
372
|
"""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
|
-
|
|
373
|
+
prompt_parts = _build_base_prompt_parts(raw_content, parsed_content, recipe_name)
|
|
374
|
+
|
|
375
|
+
# Add project context if available
|
|
376
|
+
if project_recommendations:
|
|
377
|
+
prompt_parts.extend(
|
|
378
|
+
_build_project_context_parts(project_recommendations, recipe_name)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
prompt_parts.extend(_build_conversion_requirements_parts())
|
|
382
|
+
|
|
383
|
+
# Add project-specific guidance if available
|
|
384
|
+
if project_recommendations:
|
|
385
|
+
prompt_parts.extend(_build_project_guidance_parts(project_recommendations))
|
|
386
|
+
|
|
387
|
+
prompt_parts.extend(_build_output_format_parts())
|
|
388
|
+
|
|
389
|
+
return "\n".join(prompt_parts)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _build_base_prompt_parts(
|
|
393
|
+
raw_content: str, parsed_content: str, recipe_name: str
|
|
394
|
+
) -> list[str]:
|
|
395
|
+
"""Build the base prompt parts."""
|
|
396
|
+
return [
|
|
397
|
+
"You are an expert at converting Chef recipes to Ansible playbooks.",
|
|
398
|
+
"Your task is to convert the following Chef recipe into a high-quality,",
|
|
399
|
+
"production-ready Ansible playbook.",
|
|
400
|
+
"",
|
|
401
|
+
"CHEF RECIPE CONTENT:",
|
|
402
|
+
raw_content,
|
|
403
|
+
"",
|
|
404
|
+
"PARSED RECIPE ANALYSIS:",
|
|
405
|
+
parsed_content,
|
|
406
|
+
"",
|
|
407
|
+
f"RECIPE NAME: {recipe_name}",
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _build_project_context_parts(
|
|
412
|
+
project_recommendations: dict, recipe_name: str
|
|
413
|
+
) -> list[str]:
|
|
414
|
+
"""Build project context parts."""
|
|
415
|
+
# Extract values to shorten f-strings
|
|
416
|
+
complexity = project_recommendations.get("project_complexity", "Unknown")
|
|
417
|
+
strategy = project_recommendations.get("migration_strategy", "Unknown")
|
|
418
|
+
effort_days = project_recommendations.get("project_effort_days", 0)
|
|
419
|
+
density = project_recommendations.get("dependency_density", 0)
|
|
420
|
+
|
|
421
|
+
parts = [
|
|
422
|
+
"",
|
|
423
|
+
"PROJECT CONTEXT:",
|
|
424
|
+
f"Project Complexity: {complexity}",
|
|
425
|
+
f"Migration Strategy: {strategy}",
|
|
426
|
+
f"Total Project Effort: {effort_days:.1f} days",
|
|
427
|
+
f"Dependency Density: {density:.2f}",
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
# Add migration recommendations
|
|
431
|
+
recommendations = project_recommendations.get("recommendations", [])
|
|
432
|
+
if recommendations:
|
|
433
|
+
parts.extend(
|
|
434
|
+
[
|
|
435
|
+
"",
|
|
436
|
+
"PROJECT MIGRATION RECOMMENDATIONS:",
|
|
437
|
+
]
|
|
438
|
+
)
|
|
439
|
+
for rec in recommendations[:5]: # Limit to first 5 recommendations
|
|
440
|
+
parts.append(f"- {rec}")
|
|
441
|
+
|
|
442
|
+
# Add dependency information
|
|
443
|
+
migration_order = project_recommendations.get("migration_order", [])
|
|
444
|
+
if migration_order:
|
|
445
|
+
recipe_position = _find_recipe_position_in_migration_order(
|
|
446
|
+
migration_order, recipe_name
|
|
447
|
+
)
|
|
448
|
+
if recipe_position:
|
|
449
|
+
dependencies = ", ".join(recipe_position.get("dependencies", [])) or "None"
|
|
450
|
+
parts.extend(
|
|
451
|
+
[
|
|
452
|
+
"",
|
|
453
|
+
"MIGRATION CONTEXT FOR THIS RECIPE:",
|
|
454
|
+
f"Phase: {recipe_position.get('phase', 'Unknown')}",
|
|
455
|
+
f"Complexity: {recipe_position.get('complexity', 'Unknown')}",
|
|
456
|
+
f"Dependencies: {dependencies}",
|
|
457
|
+
f"Migration Reason: {recipe_position.get('reason', 'Unknown')}",
|
|
458
|
+
]
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return parts
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _find_recipe_position_in_migration_order(
|
|
465
|
+
migration_order: list[dict[str, Any]], recipe_name: str
|
|
466
|
+
) -> dict[str, Any] | None:
|
|
467
|
+
"""Find this recipe's position in migration order."""
|
|
468
|
+
for item in migration_order:
|
|
469
|
+
cookbook_name = recipe_name.replace(".rb", "").replace("recipes/", "")
|
|
470
|
+
if item.get("cookbook") == cookbook_name:
|
|
471
|
+
return item
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _build_conversion_requirements_parts() -> list[str]:
|
|
476
|
+
"""Build conversion requirements parts."""
|
|
477
|
+
return [
|
|
478
|
+
"",
|
|
479
|
+
"CONVERSION REQUIREMENTS:",
|
|
480
|
+
"",
|
|
481
|
+
"1. **Understand the Intent**: Analyze what this Chef recipe is trying to",
|
|
482
|
+
" accomplish. Look at the resources, their properties, and the overall",
|
|
483
|
+
" workflow.",
|
|
484
|
+
"",
|
|
485
|
+
"2. **Best Practices**: Generate Ansible code that follows Ansible best",
|
|
486
|
+
" practices:",
|
|
487
|
+
" - Use appropriate modules (ansible.builtin.* when possible)",
|
|
488
|
+
" - Include proper error handling and idempotency",
|
|
489
|
+
" - Use meaningful variable names",
|
|
490
|
+
" - Include comments explaining complex logic",
|
|
491
|
+
" - Handle edge cases and failure scenarios",
|
|
492
|
+
"",
|
|
493
|
+
"3. **Resource Mapping**: Convert Chef resources to appropriate Ansible",
|
|
494
|
+
" modules:",
|
|
495
|
+
" - package → ansible.builtin.package or specific package managers",
|
|
496
|
+
" - service → ansible.builtin.service",
|
|
497
|
+
" - file/directory → ansible.builtin.file",
|
|
498
|
+
" - template → ansible.builtin.template (CHANGE .erb to .j2 extension)",
|
|
499
|
+
" - execute → ansible.builtin.command/shell",
|
|
500
|
+
" - user/group → ansible.builtin.user/group",
|
|
501
|
+
" - mount → ansible.builtin.mount",
|
|
502
|
+
" - include_recipe → ansible.builtin.include_role (for unknown cookbooks)",
|
|
503
|
+
" - include_recipe → specific role imports (for known cookbooks)",
|
|
504
|
+
"",
|
|
505
|
+
"4. **Template Conversions**: CRITICAL for template resources:",
|
|
506
|
+
" - Change file extension from .erb to .j2 in the 'src' parameter",
|
|
507
|
+
" - Example: 'config.erb' becomes 'config.j2'",
|
|
508
|
+
" - Add note: Templates need manual ERB→Jinja2 conversion",
|
|
509
|
+
" - ERB syntax: <%= variable %> → Jinja2: {{ variable }}",
|
|
510
|
+
" - ERB blocks: <% code %> → Jinja2: {% code %}",
|
|
511
|
+
"",
|
|
512
|
+
"5. **Chef Data Bags to Ansible Vault**: Convert data bag lookups:",
|
|
513
|
+
" - Chef::EncryptedDataBagItem.load('bag', 'item') →",
|
|
514
|
+
" - Ansible: Use group_vars/ or host_vars/ with ansible-vault",
|
|
515
|
+
" - Store sensitive data in encrypted YAML files, not inline lookups",
|
|
516
|
+
" - Example: Define ssh_key in group_vars/all/vault.yml (encrypted)",
|
|
517
|
+
"",
|
|
518
|
+
"6. **Variables and Facts**: Convert Chef node attributes to Ansible",
|
|
519
|
+
" variables/facts appropriately:",
|
|
520
|
+
" - node['attribute'] → {{ ansible_facts.attribute }} or vars",
|
|
521
|
+
" - node['platform'] → {{ ansible_distribution }}",
|
|
522
|
+
" - node['platform_version'] → {{ ansible_distribution_version }}",
|
|
523
|
+
"",
|
|
524
|
+
"7. **Conditionals**: Convert Chef guards (only_if/not_if) to Ansible when",
|
|
525
|
+
" conditions.",
|
|
526
|
+
"",
|
|
527
|
+
"8. **Notifications**: Convert Chef notifications to Ansible handlers",
|
|
528
|
+
" where appropriate.",
|
|
529
|
+
"",
|
|
530
|
+
"9. **Idempotency**: Ensure the playbook is idempotent and can be run",
|
|
531
|
+
" multiple times safely.",
|
|
532
|
+
"",
|
|
533
|
+
"10. **Error Handling**: Include proper error handling and rollback",
|
|
534
|
+
" considerations.",
|
|
535
|
+
"",
|
|
536
|
+
"11. **Task Ordering**: CRITICAL: Ensure tasks are ordered logically.",
|
|
537
|
+
" - Install packages BEFORE configuring them.",
|
|
538
|
+
" - create users/groups BEFORE using them in file permissions.",
|
|
539
|
+
" - Place configuration files BEFORE starting/restarting services.",
|
|
540
|
+
" - Ensure directories exist BEFORE creating files in them.",
|
|
541
|
+
"",
|
|
542
|
+
"12. **Handlers**: Verify that all notified handlers are actually defined",
|
|
543
|
+
" in the handlers section.",
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _build_project_guidance_parts(project_recommendations: dict) -> list[str]:
|
|
548
|
+
"""Build project-specific guidance parts."""
|
|
549
|
+
strategy = project_recommendations.get("migration_strategy", "").lower()
|
|
550
|
+
parts = []
|
|
551
|
+
|
|
552
|
+
if "parallel" in strategy:
|
|
553
|
+
parallel_tracks = project_recommendations.get("parallel_tracks", 2)
|
|
554
|
+
parts.extend(
|
|
555
|
+
[
|
|
556
|
+
"",
|
|
557
|
+
"11. **Parallel Migration Context**: This recipe is part of a",
|
|
558
|
+
f" parallel migration with {parallel_tracks} tracks.",
|
|
559
|
+
" Ensure this playbook can run independently without",
|
|
560
|
+
" dependencies on other cookbooks in the project.",
|
|
561
|
+
]
|
|
562
|
+
)
|
|
563
|
+
elif "phased" in strategy:
|
|
564
|
+
parts.extend(
|
|
565
|
+
[
|
|
566
|
+
"",
|
|
567
|
+
"11. **Phased Migration Context**: This recipe is part of a phased",
|
|
568
|
+
" migration approach. Consider dependencies and ensure proper",
|
|
569
|
+
" ordering within the broader project migration plan.",
|
|
570
|
+
]
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return parts
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _build_output_format_parts() -> list[str]:
|
|
577
|
+
"""Build output format parts."""
|
|
578
|
+
return [
|
|
579
|
+
"",
|
|
580
|
+
"OUTPUT FORMAT:",
|
|
581
|
+
"Return ONLY a valid YAML Ansible playbook. Do not include any",
|
|
582
|
+
" explanation, markdown formatting, or code blocks. The output should",
|
|
583
|
+
" be pure YAML that can be directly used as an Ansible playbook.",
|
|
584
|
+
"",
|
|
585
|
+
"CRITICAL YAML SYNTAX RULES:",
|
|
586
|
+
"- Use block mapping style (one key per line) NOT flow mapping style",
|
|
587
|
+
"- NEVER use { } for mappings with conditionals or complex expressions",
|
|
588
|
+
"- Correct: Use multi-line format:",
|
|
589
|
+
" - name: Include role conditionally",
|
|
590
|
+
" ansible.builtin.import_role:",
|
|
591
|
+
" name: my_role",
|
|
592
|
+
" when: condition_here",
|
|
593
|
+
"- WRONG: { role: my_role, when: condition } # This is INVALID",
|
|
594
|
+
"",
|
|
595
|
+
"The playbook should include:",
|
|
596
|
+
"- A proper name",
|
|
597
|
+
"- Appropriate hosts (default to 'all')",
|
|
598
|
+
"- Variables section if needed",
|
|
599
|
+
"- Tasks section with all converted resources",
|
|
600
|
+
"- Handlers section if notifications are used",
|
|
601
|
+
"- Any necessary pre_tasks or post_tasks",
|
|
602
|
+
"",
|
|
603
|
+
"Example structure:",
|
|
604
|
+
"---",
|
|
605
|
+
"- name: Convert of <recipe_name>",
|
|
606
|
+
" hosts: all",
|
|
607
|
+
" become: true",
|
|
608
|
+
" vars:",
|
|
609
|
+
" # Variables here",
|
|
610
|
+
" tasks:",
|
|
611
|
+
" # Tasks here",
|
|
612
|
+
" handlers:",
|
|
613
|
+
" # Handlers here",
|
|
614
|
+
"",
|
|
615
|
+
"Focus on creating a functional, well-structured Ansible playbook that",
|
|
616
|
+
"achieves the same outcome as the Chef recipe.",
|
|
617
|
+
]
|
|
426
618
|
|
|
427
619
|
|
|
428
620
|
def _clean_ai_playbook_response(ai_response: str) -> str:
|
|
@@ -507,10 +699,18 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
|
|
|
507
699
|
if shutil.which("ansible-lint") is None:
|
|
508
700
|
return None
|
|
509
701
|
|
|
702
|
+
tmp_path = None
|
|
510
703
|
try:
|
|
511
|
-
with
|
|
512
|
-
|
|
513
|
-
|
|
704
|
+
# Create temp file with secure permissions (0o600 = rw-------)
|
|
705
|
+
# Use os.open with secure flags instead of NamedTemporaryFile for better control
|
|
706
|
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".yml", text=True)
|
|
707
|
+
try:
|
|
708
|
+
# Write content to file descriptor (atomic operation)
|
|
709
|
+
with os.fdopen(tmp_fd, "w") as tmp:
|
|
710
|
+
tmp.write(playbook_content)
|
|
711
|
+
except Exception:
|
|
712
|
+
os.close(tmp_fd)
|
|
713
|
+
raise
|
|
514
714
|
|
|
515
715
|
# Run ansible-lint
|
|
516
716
|
# We ignore return code because we want to capture output even on failure
|
|
@@ -528,7 +728,7 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
|
|
|
528
728
|
except Exception:
|
|
529
729
|
return None
|
|
530
730
|
finally:
|
|
531
|
-
if
|
|
731
|
+
if tmp_path is not None and Path(tmp_path).exists():
|
|
532
732
|
Path(tmp_path).unlink()
|
|
533
733
|
|
|
534
734
|
|
|
@@ -599,8 +799,9 @@ def analyse_chef_search_patterns(recipe_or_cookbook_path: str) -> str:
|
|
|
599
799
|
path_obj = _normalize_path(recipe_or_cookbook_path)
|
|
600
800
|
|
|
601
801
|
if path_obj.is_file():
|
|
602
|
-
# Single recipe file
|
|
603
|
-
|
|
802
|
+
# Single recipe file - use parent directory as base path
|
|
803
|
+
base_path = path_obj.parent
|
|
804
|
+
search_patterns = _extract_search_patterns_from_file(path_obj, base_path)
|
|
604
805
|
elif path_obj.is_dir():
|
|
605
806
|
# Cookbook directory
|
|
606
807
|
search_patterns = _extract_search_patterns_from_cookbook(path_obj)
|
|
@@ -996,9 +1197,8 @@ def main():
|
|
|
996
1197
|
if __name__ == "__main__":
|
|
997
1198
|
main()
|
|
998
1199
|
'''
|
|
999
|
-
|
|
1000
1200
|
# Convert queries_data to JSON string for embedding
|
|
1001
|
-
queries_json = json.dumps(
|
|
1201
|
+
queries_json = json.dumps( # nosonar
|
|
1002
1202
|
{
|
|
1003
1203
|
item.get("group_name", f"group_{i}"): item.get("search_query", "")
|
|
1004
1204
|
for i, item in enumerate(queries_data)
|
|
@@ -1012,39 +1212,66 @@ if __name__ == "__main__":
|
|
|
1012
1212
|
# Search pattern extraction
|
|
1013
1213
|
|
|
1014
1214
|
|
|
1015
|
-
def _extract_search_patterns_from_file(
|
|
1016
|
-
|
|
1215
|
+
def _extract_search_patterns_from_file(
|
|
1216
|
+
file_path: Path, base_path: Path
|
|
1217
|
+
) -> list[dict[str, str]]:
|
|
1218
|
+
"""
|
|
1219
|
+
Extract Chef search patterns from a single recipe file.
|
|
1220
|
+
|
|
1221
|
+
Args:
|
|
1222
|
+
file_path: Path to the file to parse.
|
|
1223
|
+
base_path: Base directory for path validation.
|
|
1224
|
+
|
|
1225
|
+
Returns:
|
|
1226
|
+
List of search patterns found in the file.
|
|
1227
|
+
|
|
1228
|
+
"""
|
|
1017
1229
|
try:
|
|
1018
|
-
content = file_path
|
|
1230
|
+
content = safe_read_text(file_path, base_path)
|
|
1019
1231
|
return _find_search_patterns_in_content(content, str(file_path))
|
|
1020
1232
|
except Exception:
|
|
1021
1233
|
return []
|
|
1022
1234
|
|
|
1023
1235
|
|
|
1024
1236
|
def _extract_search_patterns_from_cookbook(cookbook_path: Path) -> list[dict[str, str]]:
|
|
1025
|
-
"""
|
|
1237
|
+
"""
|
|
1238
|
+
Extract Chef search patterns from all files in a cookbook.
|
|
1239
|
+
|
|
1240
|
+
Args:
|
|
1241
|
+
cookbook_path: Path to the cookbook directory.
|
|
1242
|
+
|
|
1243
|
+
Returns:
|
|
1244
|
+
List of all search patterns found in the cookbook.
|
|
1245
|
+
|
|
1246
|
+
"""
|
|
1026
1247
|
patterns = []
|
|
1027
1248
|
|
|
1028
|
-
# Search in recipes directory
|
|
1249
|
+
# Search in recipes directory using safe_glob
|
|
1029
1250
|
recipes_dir = _safe_join(cookbook_path, "recipes")
|
|
1030
|
-
if recipes_dir
|
|
1031
|
-
for recipe_file in recipes_dir
|
|
1032
|
-
|
|
1033
|
-
|
|
1251
|
+
if safe_exists(recipes_dir, cookbook_path):
|
|
1252
|
+
for recipe_file in safe_glob(recipes_dir, "*.rb", cookbook_path):
|
|
1253
|
+
patterns_found = _extract_search_patterns_from_file(
|
|
1254
|
+
recipe_file, cookbook_path
|
|
1255
|
+
)
|
|
1256
|
+
patterns.extend(patterns_found)
|
|
1034
1257
|
|
|
1035
|
-
# Search in libraries directory
|
|
1258
|
+
# Search in libraries directory using safe_glob
|
|
1036
1259
|
libraries_dir = _safe_join(cookbook_path, "libraries")
|
|
1037
|
-
if libraries_dir
|
|
1038
|
-
for library_file in libraries_dir
|
|
1039
|
-
|
|
1040
|
-
|
|
1260
|
+
if safe_exists(libraries_dir, cookbook_path):
|
|
1261
|
+
for library_file in safe_glob(libraries_dir, "*.rb", cookbook_path):
|
|
1262
|
+
patterns_found = _extract_search_patterns_from_file(
|
|
1263
|
+
library_file, cookbook_path
|
|
1264
|
+
)
|
|
1265
|
+
patterns.extend(patterns_found)
|
|
1041
1266
|
|
|
1042
|
-
# Search in resources directory
|
|
1267
|
+
# Search in resources directory using safe_glob
|
|
1043
1268
|
resources_dir = _safe_join(cookbook_path, "resources")
|
|
1044
|
-
if resources_dir
|
|
1045
|
-
for resource_file in resources_dir
|
|
1046
|
-
|
|
1047
|
-
|
|
1269
|
+
if safe_exists(resources_dir, cookbook_path):
|
|
1270
|
+
for resource_file in safe_glob(resources_dir, "*.rb", cookbook_path):
|
|
1271
|
+
patterns_found = _extract_search_patterns_from_file(
|
|
1272
|
+
resource_file, cookbook_path
|
|
1273
|
+
)
|
|
1274
|
+
patterns.extend(patterns_found)
|
|
1048
1275
|
|
|
1049
1276
|
return patterns
|
|
1050
1277
|
|
|
@@ -1261,19 +1488,32 @@ def _build_playbook_header(recipe_name: str) -> list[str]:
|
|
|
1261
1488
|
def _add_playbook_variables(
|
|
1262
1489
|
playbook_lines: list[str], raw_content: str, recipe_file: Path
|
|
1263
1490
|
) -> None:
|
|
1264
|
-
"""
|
|
1491
|
+
"""
|
|
1492
|
+
Extract and add variables section to playbook.
|
|
1493
|
+
|
|
1494
|
+
Args:
|
|
1495
|
+
playbook_lines: List of playbook lines to add variables to.
|
|
1496
|
+
raw_content: Raw recipe file content.
|
|
1497
|
+
recipe_file: Path to the recipe file, normalized and contained within cookbook.
|
|
1498
|
+
|
|
1499
|
+
"""
|
|
1265
1500
|
variables = _extract_recipe_variables(raw_content)
|
|
1266
1501
|
|
|
1267
|
-
# Try to parse attributes file
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1502
|
+
# Try to parse attributes file - validate it stays within cookbook
|
|
1503
|
+
cookbook_path = recipe_file.parent.parent
|
|
1504
|
+
attributes_path = _safe_join(cookbook_path, "attributes", "default.rb")
|
|
1505
|
+
try:
|
|
1506
|
+
if safe_exists(attributes_path, cookbook_path):
|
|
1507
|
+
attributes_content = parse_attributes(str(attributes_path))
|
|
1508
|
+
if not attributes_content.startswith(
|
|
1509
|
+
"Error:"
|
|
1510
|
+
) and not attributes_content.startswith("Warning:"):
|
|
1511
|
+
# Parse the resolved attributes
|
|
1512
|
+
attr_vars = _extract_attribute_variables(attributes_content)
|
|
1513
|
+
variables.update(attr_vars)
|
|
1514
|
+
except ValueError:
|
|
1515
|
+
# Path traversal attempt detected - skip safely
|
|
1516
|
+
pass
|
|
1277
1517
|
|
|
1278
1518
|
for var_name, var_value in variables.items():
|
|
1279
1519
|
playbook_lines.append(f" {var_name}: {var_value}")
|