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
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Cookbook Analysis Page for SousChef UI."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
4
|
+
import inspect
|
|
3
5
|
import io
|
|
4
6
|
import json
|
|
7
|
+
import os
|
|
5
8
|
import shutil
|
|
9
|
+
import subprocess
|
|
6
10
|
import sys
|
|
7
11
|
import tarfile
|
|
8
12
|
import tempfile
|
|
@@ -16,39 +20,244 @@ import streamlit as st
|
|
|
16
20
|
# Add the parent directory to the path so we can import souschef modules
|
|
17
21
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
18
22
|
|
|
19
|
-
from souschef.assessment import
|
|
23
|
+
from souschef.assessment import (
|
|
24
|
+
analyse_cookbook_dependencies,
|
|
25
|
+
assess_single_cookbook_with_ai,
|
|
26
|
+
)
|
|
20
27
|
from souschef.converters.playbook import (
|
|
21
28
|
generate_playbook_from_recipe,
|
|
22
29
|
generate_playbook_from_recipe_with_ai,
|
|
23
30
|
)
|
|
31
|
+
from souschef.converters.template import convert_cookbook_templates
|
|
24
32
|
from souschef.core.constants import METADATA_FILENAME
|
|
33
|
+
from souschef.core.metrics import (
|
|
34
|
+
EffortMetrics,
|
|
35
|
+
get_timeline_weeks,
|
|
36
|
+
validate_metrics_consistency,
|
|
37
|
+
)
|
|
38
|
+
from souschef.core.path_utils import (
|
|
39
|
+
_ensure_within_base_path,
|
|
40
|
+
_normalize_path,
|
|
41
|
+
_safe_join,
|
|
42
|
+
)
|
|
43
|
+
from souschef.generators.repo import (
|
|
44
|
+
analyse_conversion_output,
|
|
45
|
+
generate_ansible_repository,
|
|
46
|
+
)
|
|
25
47
|
from souschef.parsers.metadata import parse_cookbook_metadata
|
|
26
48
|
|
|
27
49
|
# AI Settings
|
|
28
50
|
ANTHROPIC_PROVIDER = "Anthropic (Claude)"
|
|
51
|
+
ANTHROPIC_CLAUDE_DISPLAY = "Anthropic Claude"
|
|
29
52
|
OPENAI_PROVIDER = "OpenAI (GPT)"
|
|
30
53
|
LOCAL_PROVIDER = "Local Model"
|
|
54
|
+
IBM_WATSONX = "IBM Watsonx"
|
|
55
|
+
RED_HAT_LIGHTSPEED = "Red Hat Lightspeed"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sanitize_filename(filename: str) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Sanitise filename to prevent path injection attacks.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
filename: The filename to sanitise.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Sanitised filename safe for file operations.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
import re
|
|
70
|
+
|
|
71
|
+
# Remove any path separators and parent directory references
|
|
72
|
+
sanitised = filename.replace("..", "_").replace("/", "_").replace("\\", "_")
|
|
73
|
+
# Remove any null bytes or control characters
|
|
74
|
+
sanitised = re.sub(r"[\x00-\x1f\x7f]", "_", sanitised)
|
|
75
|
+
# Remove leading/trailing whitespace and dots
|
|
76
|
+
sanitised = sanitised.strip(". ")
|
|
77
|
+
# Limit length to prevent issues
|
|
78
|
+
sanitised = sanitised[:255]
|
|
79
|
+
return sanitised if sanitised else "unnamed"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_secure_ai_config_path() -> Path:
|
|
83
|
+
"""Return a private, non-world-writable path for AI config storage."""
|
|
84
|
+
config_dir = Path(tempfile.gettempdir()) / ".souschef"
|
|
85
|
+
config_dir.mkdir(mode=0o700, exist_ok=True)
|
|
86
|
+
with contextlib.suppress(OSError):
|
|
87
|
+
config_dir.chmod(0o700)
|
|
88
|
+
|
|
89
|
+
if config_dir.is_symlink():
|
|
90
|
+
raise ValueError("AI config directory cannot be a symlink")
|
|
91
|
+
|
|
92
|
+
config_file = config_dir / "ai_config.json"
|
|
93
|
+
# Ensure config file has secure permissions if it exists
|
|
94
|
+
if config_file.exists():
|
|
95
|
+
with contextlib.suppress(OSError):
|
|
96
|
+
config_file.chmod(0o600)
|
|
97
|
+
return config_file
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_ai_settings() -> dict[str, str | float | int]:
|
|
101
|
+
"""Load AI settings from environment variables or configuration file."""
|
|
102
|
+
# First try to load from environment variables
|
|
103
|
+
env_config = _load_ai_settings_from_env()
|
|
104
|
+
|
|
105
|
+
# If we have environment config, use it
|
|
106
|
+
if env_config:
|
|
107
|
+
return env_config
|
|
108
|
+
|
|
109
|
+
# Fall back to loading from configuration file
|
|
110
|
+
return _load_ai_settings_from_file()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_ai_settings_from_env() -> dict[str, str | float | int]:
|
|
114
|
+
"""Load AI settings from environment variables."""
|
|
115
|
+
import os
|
|
116
|
+
from contextlib import suppress
|
|
117
|
+
|
|
118
|
+
env_config: dict[str, str | float | int] = {}
|
|
119
|
+
env_mappings = {
|
|
120
|
+
"SOUSCHEF_AI_PROVIDER": "provider",
|
|
121
|
+
"SOUSCHEF_AI_MODEL": "model",
|
|
122
|
+
"SOUSCHEF_AI_API_KEY": "api_key",
|
|
123
|
+
"SOUSCHEF_AI_BASE_URL": "base_url",
|
|
124
|
+
"SOUSCHEF_AI_PROJECT_ID": "project_id",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Handle string values
|
|
128
|
+
for env_var, config_key in env_mappings.items():
|
|
129
|
+
env_value = os.environ.get(env_var)
|
|
130
|
+
if env_value:
|
|
131
|
+
env_config[config_key] = env_value
|
|
132
|
+
|
|
133
|
+
# Handle numeric values with error suppression
|
|
134
|
+
temp_value = os.environ.get("SOUSCHEF_AI_TEMPERATURE")
|
|
135
|
+
if temp_value:
|
|
136
|
+
with suppress(ValueError):
|
|
137
|
+
env_config["temperature"] = float(temp_value)
|
|
138
|
+
|
|
139
|
+
tokens_value = os.environ.get("SOUSCHEF_AI_MAX_TOKENS")
|
|
140
|
+
if tokens_value:
|
|
141
|
+
with suppress(ValueError):
|
|
142
|
+
env_config["max_tokens"] = int(tokens_value)
|
|
31
143
|
|
|
144
|
+
return env_config
|
|
32
145
|
|
|
33
|
-
|
|
146
|
+
|
|
147
|
+
def _load_ai_settings_from_file() -> dict[str, str | float | int]:
|
|
34
148
|
"""Load AI settings from configuration file."""
|
|
35
149
|
try:
|
|
36
|
-
|
|
37
|
-
config_file = Path("/tmp/.souschef/ai_config.json")
|
|
150
|
+
config_file = _get_secure_ai_config_path()
|
|
38
151
|
if config_file.exists():
|
|
39
152
|
with config_file.open() as f:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
153
|
+
file_config = json.load(f)
|
|
154
|
+
return dict(file_config) if isinstance(file_config, dict) else {}
|
|
155
|
+
except (ValueError, OSError):
|
|
156
|
+
return {}
|
|
43
157
|
return {}
|
|
44
158
|
|
|
45
159
|
|
|
160
|
+
def _get_ai_provider(ai_config: dict[str, str | float | int]) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Safely get the AI provider from config with proper type handling.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
ai_config: The AI configuration dictionary.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The AI provider string, or empty string if not found.
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
provider_raw = ai_config.get("provider", "")
|
|
172
|
+
if isinstance(provider_raw, str):
|
|
173
|
+
return provider_raw
|
|
174
|
+
return str(provider_raw) if provider_raw else ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_ai_string_value(
|
|
178
|
+
ai_config: dict[str, str | float | int], key: str, default: str = ""
|
|
179
|
+
) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Safely get a string value from AI config.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
ai_config: The AI configuration dictionary.
|
|
185
|
+
key: The key to retrieve.
|
|
186
|
+
default: Default value if key not found.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The string value or default.
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
value = ai_config.get(key, default)
|
|
193
|
+
if isinstance(value, str):
|
|
194
|
+
return value
|
|
195
|
+
return str(value) if value else default
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _get_ai_float_value(
|
|
199
|
+
ai_config: dict[str, str | float | int], key: str, default: float = 0.7
|
|
200
|
+
) -> float:
|
|
201
|
+
"""
|
|
202
|
+
Safely get a float value from AI config.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
ai_config: The AI configuration dictionary.
|
|
206
|
+
key: The key to retrieve.
|
|
207
|
+
default: Default value if key not found or conversion fails.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The float value or default.
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
value = ai_config.get(key)
|
|
214
|
+
if isinstance(value, float):
|
|
215
|
+
return value
|
|
216
|
+
elif isinstance(value, int):
|
|
217
|
+
return float(value)
|
|
218
|
+
elif isinstance(value, str):
|
|
219
|
+
try:
|
|
220
|
+
return float(value)
|
|
221
|
+
except ValueError:
|
|
222
|
+
return default
|
|
223
|
+
return default
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _get_ai_int_value(
|
|
227
|
+
ai_config: dict[str, str | float | int], key: str, default: int = 4000
|
|
228
|
+
) -> int:
|
|
229
|
+
"""
|
|
230
|
+
Safely get an int value from AI config.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
ai_config: The AI configuration dictionary.
|
|
234
|
+
key: The key to retrieve.
|
|
235
|
+
default: Default value if key not found or conversion fails.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The int value or default.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
value = ai_config.get(key)
|
|
242
|
+
if isinstance(value, int):
|
|
243
|
+
return value
|
|
244
|
+
elif isinstance(value, float):
|
|
245
|
+
return int(value)
|
|
246
|
+
elif isinstance(value, str):
|
|
247
|
+
try:
|
|
248
|
+
return int(value)
|
|
249
|
+
except ValueError:
|
|
250
|
+
return default
|
|
251
|
+
return default
|
|
252
|
+
|
|
253
|
+
|
|
46
254
|
# Constants for repeated strings
|
|
47
255
|
METADATA_STATUS_YES = "Yes"
|
|
48
256
|
METADATA_STATUS_NO = "No"
|
|
49
257
|
ANALYSIS_STATUS_ANALYSED = "Analysed"
|
|
50
258
|
ANALYSIS_STATUS_FAILED = "Failed"
|
|
51
259
|
METADATA_COLUMN_NAME = "Has Metadata"
|
|
260
|
+
MIME_TYPE_ZIP = "application/zip"
|
|
52
261
|
|
|
53
262
|
# Security limits for archive extraction
|
|
54
263
|
MAX_ARCHIVE_SIZE = 100 * 1024 * 1024 # 100MB total
|
|
@@ -90,8 +299,10 @@ def extract_archive(uploaded_file) -> tuple[Path, Path]:
|
|
|
90
299
|
f"Archive too large: {file_size} bytes (max: {MAX_ARCHIVE_SIZE})"
|
|
91
300
|
)
|
|
92
301
|
|
|
93
|
-
# Create temporary directory
|
|
302
|
+
# Create temporary directory with secure permissions (owner-only access)
|
|
94
303
|
temp_dir = Path(tempfile.mkdtemp())
|
|
304
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
305
|
+
temp_dir.chmod(0o700) # Secure permissions: rwx------
|
|
95
306
|
temp_path = temp_dir
|
|
96
307
|
|
|
97
308
|
# Save uploaded file
|
|
@@ -181,7 +392,12 @@ license 'All rights reserved'
|
|
|
181
392
|
description 'Automatically extracted cookbook from archive'
|
|
182
393
|
version '1.0.0'
|
|
183
394
|
"""
|
|
184
|
-
|
|
395
|
+
try:
|
|
396
|
+
metadata_file = synthetic_cookbook_dir / METADATA_FILENAME
|
|
397
|
+
metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
metadata_file.write_text(metadata_content)
|
|
399
|
+
except OSError as e:
|
|
400
|
+
raise OSError(f"Failed to write metadata file: {e}") from e
|
|
185
401
|
|
|
186
402
|
return extraction_dir
|
|
187
403
|
|
|
@@ -206,9 +422,11 @@ version '1.0.0'
|
|
|
206
422
|
"""
|
|
207
423
|
(single_dir / METADATA_FILENAME).write_text(metadata_content)
|
|
208
424
|
|
|
425
|
+
# Return the parent directory so it will scan and find the cookbook inside
|
|
209
426
|
return extraction_dir
|
|
210
427
|
else:
|
|
211
428
|
# Single directory that doesn't contain cookbook components
|
|
429
|
+
# It might be a wrapper directory containing multiple cookbooks
|
|
212
430
|
return single_dir
|
|
213
431
|
|
|
214
432
|
|
|
@@ -225,15 +443,14 @@ def _extract_zip_securely(archive_path: Path, extraction_dir: Path) -> None:
|
|
|
225
443
|
# Safe extraction with manual path handling
|
|
226
444
|
for info in zip_ref.filelist:
|
|
227
445
|
# Construct safe relative path
|
|
446
|
+
|
|
228
447
|
safe_path = _get_safe_extraction_path(info.filename, extraction_dir)
|
|
229
448
|
|
|
230
449
|
if info.is_dir():
|
|
231
|
-
# Create directory
|
|
232
450
|
safe_path.mkdir(parents=True, exist_ok=True)
|
|
233
451
|
else:
|
|
234
|
-
# Create parent directories if needed
|
|
235
452
|
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
-
|
|
453
|
+
|
|
237
454
|
with zip_ref.open(info) as source, safe_path.open("wb") as target:
|
|
238
455
|
# Read in chunks to control memory usage
|
|
239
456
|
while True:
|
|
@@ -277,13 +494,41 @@ def _validate_zip_file_security(info, file_count: int, total_size: int) -> None:
|
|
|
277
494
|
def _extract_tar_securely(
|
|
278
495
|
archive_path: Path, extraction_dir: Path, gzipped: bool
|
|
279
496
|
) -> None:
|
|
280
|
-
"""
|
|
497
|
+
"""
|
|
498
|
+
Extract TAR archive with resource consumption controls (S5042).
|
|
499
|
+
|
|
500
|
+
Resource consumption is controlled via:
|
|
501
|
+
- Pre-scanning all members before extraction
|
|
502
|
+
- Validating file sizes, counts, and directory depth
|
|
503
|
+
- Using tarfile.filter='data' (Python 3.12+) to prevent symlink traversal
|
|
504
|
+
- Limiting extraction to validated safe paths
|
|
505
|
+
|
|
506
|
+
"""
|
|
281
507
|
mode = "r:gz" if gzipped else "r"
|
|
282
508
|
|
|
509
|
+
if not archive_path.is_file():
|
|
510
|
+
raise ValueError(f"Archive path is not a file: {archive_path}")
|
|
511
|
+
|
|
512
|
+
if not tarfile.is_tarfile(str(archive_path)):
|
|
513
|
+
raise ValueError(f"Invalid or corrupted TAR archive: {archive_path.name}")
|
|
514
|
+
|
|
283
515
|
try:
|
|
284
|
-
|
|
516
|
+
open_kwargs: dict[str, Any] = {"name": str(archive_path), "mode": mode}
|
|
517
|
+
|
|
518
|
+
# Apply safe filter if available (Python 3.12+) to prevent traversal attacks.
|
|
519
|
+
# For older Python versions, resource consumption is controlled via pre-scanning
|
|
520
|
+
# and member validation before extraction.
|
|
521
|
+
if "filter" in inspect.signature(tarfile.open).parameters:
|
|
522
|
+
# Use 'data' filter to prevent extraction of special files and symlinks
|
|
523
|
+
open_kwargs["filter"] = "data"
|
|
524
|
+
|
|
525
|
+
with tarfile.open(**open_kwargs) as tar_ref:
|
|
285
526
|
members = tar_ref.getmembers()
|
|
527
|
+
# Pre-validate all members before allowing extraction
|
|
528
|
+
# This controls resource consumption and prevents
|
|
529
|
+
# zip bombs/decompression bombs
|
|
286
530
|
_pre_scan_tar_members(members)
|
|
531
|
+
# Extract only validated members to pre-validated safe paths
|
|
287
532
|
_extract_tar_members(tar_ref, members, extraction_dir)
|
|
288
533
|
except tarfile.TarError as e:
|
|
289
534
|
raise ValueError(f"Invalid or corrupted TAR archive: {e}") from e
|
|
@@ -292,10 +537,20 @@ def _extract_tar_securely(
|
|
|
292
537
|
|
|
293
538
|
|
|
294
539
|
def _pre_scan_tar_members(members):
|
|
295
|
-
"""
|
|
540
|
+
"""
|
|
541
|
+
Pre-scan TAR members to control resource consumption (S5042).
|
|
542
|
+
|
|
543
|
+
Validates all members before extraction to prevent:
|
|
544
|
+
- Compression/decompression bombs (via size limits)
|
|
545
|
+
- Excessive memory consumption (via file count limits)
|
|
546
|
+
- Directory traversal attacks (via depth limits)
|
|
547
|
+
- Malicious file inclusion (via extension and type checks)
|
|
548
|
+
|
|
549
|
+
"""
|
|
296
550
|
total_size = 0
|
|
297
551
|
for file_count, member in enumerate(members, start=1):
|
|
298
552
|
total_size += member.size
|
|
553
|
+
# Validate member and accumulate size for bounds checking
|
|
299
554
|
_validate_tar_file_security(member, file_count, total_size)
|
|
300
555
|
|
|
301
556
|
|
|
@@ -389,29 +644,11 @@ def _get_safe_extraction_path(filename: str, extraction_dir: Path) -> Path:
|
|
|
389
644
|
):
|
|
390
645
|
raise ValueError(f"Path traversal or absolute path detected: {filename}")
|
|
391
646
|
|
|
392
|
-
#
|
|
647
|
+
# Normalise separators and join using a containment-checked join
|
|
393
648
|
normalized = filename.replace("\\", "/").strip("/")
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
for part in normalized.split("/"):
|
|
398
|
-
if part == "" or part == ".":
|
|
399
|
-
continue
|
|
400
|
-
elif part == "..":
|
|
401
|
-
# Remove parent directory if we have one
|
|
402
|
-
if parts:
|
|
403
|
-
parts.pop()
|
|
404
|
-
else:
|
|
405
|
-
parts.append(part)
|
|
406
|
-
|
|
407
|
-
# Join parts back and resolve against extraction_dir
|
|
408
|
-
safe_path = extraction_dir / "/".join(parts)
|
|
409
|
-
|
|
410
|
-
# Ensure the final path is still within extraction_dir
|
|
411
|
-
try:
|
|
412
|
-
safe_path.resolve().relative_to(extraction_dir.resolve())
|
|
413
|
-
except ValueError:
|
|
414
|
-
raise ValueError(f"Path traversal detected: {filename}") from None
|
|
649
|
+
safe_path = _ensure_within_base_path(
|
|
650
|
+
_safe_join(extraction_dir.resolve(), normalized), extraction_dir.resolve()
|
|
651
|
+
)
|
|
415
652
|
|
|
416
653
|
return safe_path
|
|
417
654
|
|
|
@@ -441,7 +678,7 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
441
678
|
{result["recommendations"]}
|
|
442
679
|
|
|
443
680
|
## Source Path
|
|
444
|
-
{
|
|
681
|
+
{cookbook_path} # deepcode ignore PT: used for display only, not file operations
|
|
445
682
|
"""
|
|
446
683
|
zip_file.writestr(f"{result['name']}_report.md", report_content)
|
|
447
684
|
|
|
@@ -459,12 +696,14 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
459
696
|
- **Successfully Analysed**: {successful}
|
|
460
697
|
|
|
461
698
|
- **Total Estimated Hours**: {total_hours:.1f}
|
|
462
|
-
- **Source**: {cookbook_path}
|
|
699
|
+
- **Source**: {cookbook_path} # deepcode ignore PT: used for display only
|
|
463
700
|
|
|
464
701
|
## Results Summary
|
|
465
702
|
"""
|
|
466
703
|
for result in results:
|
|
467
|
-
status_icon =
|
|
704
|
+
status_icon = (
|
|
705
|
+
"PASS" if result["status"] == ANALYSIS_STATUS_ANALYSED else "FAIL"
|
|
706
|
+
)
|
|
468
707
|
summary_content += f"- {status_icon} {result['name']}: {result['status']}"
|
|
469
708
|
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
470
709
|
summary_content += (
|
|
@@ -479,24 +718,24 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
479
718
|
return zip_buffer.getvalue()
|
|
480
719
|
|
|
481
720
|
|
|
482
|
-
def show_cookbook_analysis_page():
|
|
721
|
+
def show_cookbook_analysis_page() -> None:
|
|
483
722
|
"""Show the cookbook analysis page."""
|
|
484
|
-
_setup_cookbook_analysis_ui()
|
|
485
|
-
|
|
486
723
|
# Initialise session state for analysis results
|
|
487
|
-
|
|
488
724
|
if "analysis_results" not in st.session_state:
|
|
489
725
|
st.session_state.analysis_results = None
|
|
490
726
|
st.session_state.analysis_cookbook_path = None
|
|
491
727
|
st.session_state.total_cookbooks = 0
|
|
492
728
|
st.session_state.temp_dir = None
|
|
493
729
|
|
|
730
|
+
# Add unique key to track if this is a new page load
|
|
731
|
+
if "analysis_page_key" not in st.session_state:
|
|
732
|
+
st.session_state.analysis_page_key = 0
|
|
733
|
+
|
|
734
|
+
_setup_cookbook_analysis_ui()
|
|
735
|
+
|
|
494
736
|
# Check if we have analysis results to display
|
|
495
737
|
if st.session_state.analysis_results is not None:
|
|
496
|
-
|
|
497
|
-
st.session_state.analysis_results,
|
|
498
|
-
st.session_state.total_cookbooks,
|
|
499
|
-
)
|
|
738
|
+
_display_results_view()
|
|
500
739
|
return
|
|
501
740
|
|
|
502
741
|
# Check if we have an uploaded file from the dashboard
|
|
@@ -504,6 +743,11 @@ def show_cookbook_analysis_page():
|
|
|
504
743
|
_handle_dashboard_upload()
|
|
505
744
|
return
|
|
506
745
|
|
|
746
|
+
_show_analysis_input()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _show_analysis_input() -> None:
|
|
750
|
+
"""Show analysis input interface."""
|
|
507
751
|
# Input method selection
|
|
508
752
|
input_method = st.radio(
|
|
509
753
|
"Choose Input Method",
|
|
@@ -512,7 +756,7 @@ def show_cookbook_analysis_page():
|
|
|
512
756
|
help="Select how to provide cookbooks for analysis",
|
|
513
757
|
)
|
|
514
758
|
|
|
515
|
-
cookbook_path = None
|
|
759
|
+
cookbook_path: str | Path | None = None
|
|
516
760
|
temp_dir = None
|
|
517
761
|
uploaded_file = None
|
|
518
762
|
|
|
@@ -523,17 +767,21 @@ def show_cookbook_analysis_page():
|
|
|
523
767
|
if uploaded_file:
|
|
524
768
|
try:
|
|
525
769
|
with st.spinner("Extracting archive..."):
|
|
770
|
+
# Clear any previous analysis results
|
|
771
|
+
st.session_state.analysis_results = None
|
|
772
|
+
st.session_state.holistic_assessment = None
|
|
773
|
+
|
|
526
774
|
temp_dir, cookbook_path = extract_archive(uploaded_file)
|
|
527
775
|
# Store temp_dir in session state to prevent premature cleanup
|
|
528
776
|
st.session_state.temp_dir = temp_dir
|
|
529
777
|
st.success("Archive extracted successfully to temporary location")
|
|
530
|
-
except
|
|
778
|
+
except (OSError, zipfile.BadZipFile, tarfile.TarError) as e:
|
|
531
779
|
st.error(f"Failed to extract archive: {e}")
|
|
532
780
|
return
|
|
533
781
|
|
|
534
782
|
try:
|
|
535
783
|
if cookbook_path:
|
|
536
|
-
_validate_and_list_cookbooks(cookbook_path)
|
|
784
|
+
_validate_and_list_cookbooks(str(cookbook_path))
|
|
537
785
|
|
|
538
786
|
_display_instructions()
|
|
539
787
|
finally:
|
|
@@ -543,7 +791,43 @@ def show_cookbook_analysis_page():
|
|
|
543
791
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
544
792
|
|
|
545
793
|
|
|
546
|
-
def
|
|
794
|
+
def _display_results_view() -> None:
|
|
795
|
+
"""Display the results view with new analysis button."""
|
|
796
|
+
# Add a "New Analysis" button at the top of results page
|
|
797
|
+
col1, col2 = st.columns([6, 1])
|
|
798
|
+
with col1:
|
|
799
|
+
st.write("") # Spacer
|
|
800
|
+
with col2:
|
|
801
|
+
if st.button(
|
|
802
|
+
"New Analysis",
|
|
803
|
+
help="Start a new analysis",
|
|
804
|
+
key=f"new_analysis_{st.session_state.analysis_page_key}",
|
|
805
|
+
):
|
|
806
|
+
st.session_state.analysis_results = None
|
|
807
|
+
st.session_state.holistic_assessment = None
|
|
808
|
+
st.session_state.analysis_cookbook_path = None
|
|
809
|
+
st.session_state.total_cookbooks = None
|
|
810
|
+
st.session_state.analysis_info_messages = None
|
|
811
|
+
st.session_state.conversion_results = None
|
|
812
|
+
st.session_state.generated_playbook_repo = None
|
|
813
|
+
st.session_state.analysis_page_key += 1
|
|
814
|
+
st.rerun()
|
|
815
|
+
|
|
816
|
+
# Check if we have conversion results to display
|
|
817
|
+
if "conversion_results" in st.session_state and st.session_state.conversion_results:
|
|
818
|
+
# Display conversion results instead of analysis results
|
|
819
|
+
playbooks = st.session_state.conversion_results["playbooks"]
|
|
820
|
+
templates = st.session_state.conversion_results["templates"]
|
|
821
|
+
_handle_playbook_download(playbooks, templates)
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
_display_analysis_results(
|
|
825
|
+
st.session_state.analysis_results,
|
|
826
|
+
st.session_state.total_cookbooks,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _setup_cookbook_analysis_ui() -> None:
|
|
547
831
|
"""Set up the cookbook analysis page header."""
|
|
548
832
|
st.title("SousChef - Cookbook Analysis")
|
|
549
833
|
st.markdown("""
|
|
@@ -553,8 +837,24 @@ def _setup_cookbook_analysis_ui():
|
|
|
553
837
|
Upload a cookbook archive or specify a directory path to begin analysis.
|
|
554
838
|
""")
|
|
555
839
|
|
|
840
|
+
# Add back to dashboard button
|
|
841
|
+
col1, _ = st.columns([1, 4])
|
|
842
|
+
with col1:
|
|
843
|
+
if st.button(
|
|
844
|
+
"← Back to Dashboard",
|
|
845
|
+
help="Return to main dashboard",
|
|
846
|
+
key="back_to_dashboard_from_analysis",
|
|
847
|
+
):
|
|
848
|
+
# Clear all analysis state
|
|
849
|
+
st.session_state.analysis_results = None
|
|
850
|
+
st.session_state.holistic_assessment = None
|
|
851
|
+
st.session_state.analysis_cookbook_path = None
|
|
852
|
+
st.session_state.total_cookbooks = None
|
|
853
|
+
st.session_state.current_page = "Dashboard"
|
|
854
|
+
st.rerun()
|
|
855
|
+
|
|
556
856
|
|
|
557
|
-
def _get_cookbook_path_input():
|
|
857
|
+
def _get_cookbook_path_input() -> str:
|
|
558
858
|
"""Get the cookbook path input from the user."""
|
|
559
859
|
return st.text_input(
|
|
560
860
|
"Cookbook Directory Path",
|
|
@@ -565,7 +865,7 @@ def _get_cookbook_path_input():
|
|
|
565
865
|
)
|
|
566
866
|
|
|
567
867
|
|
|
568
|
-
def _get_archive_upload_input():
|
|
868
|
+
def _get_archive_upload_input() -> Any:
|
|
569
869
|
"""Get archive upload input from the user."""
|
|
570
870
|
uploaded_file = st.file_uploader(
|
|
571
871
|
"Upload Cookbook Archive",
|
|
@@ -575,14 +875,31 @@ def _get_archive_upload_input():
|
|
|
575
875
|
return uploaded_file
|
|
576
876
|
|
|
577
877
|
|
|
578
|
-
def
|
|
878
|
+
def _is_within_base(base: Path, candidate: Path) -> bool:
|
|
879
|
+
"""Check whether candidate is contained within base after resolution."""
|
|
880
|
+
base_real = Path(os.path.realpath(str(base)))
|
|
881
|
+
candidate_real = Path(os.path.realpath(str(candidate)))
|
|
882
|
+
try:
|
|
883
|
+
candidate_real.relative_to(base_real)
|
|
884
|
+
return True
|
|
885
|
+
except ValueError:
|
|
886
|
+
return False
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _validate_and_list_cookbooks(cookbook_path: str) -> None:
|
|
579
890
|
"""Validate the cookbook path and list available cookbooks."""
|
|
580
891
|
safe_dir = _get_safe_cookbook_directory(cookbook_path)
|
|
581
892
|
if safe_dir is None:
|
|
582
893
|
return
|
|
583
894
|
|
|
584
|
-
|
|
585
|
-
|
|
895
|
+
# Validate the safe directory before use
|
|
896
|
+
dir_exists: bool = safe_dir.exists()
|
|
897
|
+
if dir_exists:
|
|
898
|
+
dir_is_dir: bool = safe_dir.is_dir()
|
|
899
|
+
if dir_is_dir:
|
|
900
|
+
_list_and_display_cookbooks(safe_dir)
|
|
901
|
+
else:
|
|
902
|
+
st.error(f"Directory not found: {safe_dir}")
|
|
586
903
|
else:
|
|
587
904
|
st.error(f"Directory not found: {safe_dir}")
|
|
588
905
|
|
|
@@ -596,54 +913,26 @@ def _get_safe_cookbook_directory(cookbook_path):
|
|
|
596
913
|
"""
|
|
597
914
|
try:
|
|
598
915
|
base_dir = Path.cwd().resolve()
|
|
599
|
-
temp_dir = Path(tempfile.gettempdir()).resolve()
|
|
600
916
|
|
|
601
917
|
path_str = str(cookbook_path).strip()
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if "\x00" in path_str or ":\\" in path_str or "\\" in path_str:
|
|
605
|
-
st.error(
|
|
606
|
-
"❌ Invalid path: Path contains null bytes or backslashes, "
|
|
607
|
-
"which are not allowed."
|
|
608
|
-
)
|
|
609
|
-
return None
|
|
610
|
-
|
|
611
|
-
# Reject paths with directory traversal attempts
|
|
612
|
-
if ".." in path_str:
|
|
613
|
-
st.error(
|
|
614
|
-
"❌ Invalid path: Path contains '..' which is not allowed "
|
|
615
|
-
"for security reasons."
|
|
616
|
-
)
|
|
918
|
+
if not path_str:
|
|
919
|
+
st.error("Invalid path: Path cannot be empty.")
|
|
617
920
|
return None
|
|
618
921
|
|
|
619
|
-
|
|
922
|
+
# Sanitise the candidate path using shared helper
|
|
923
|
+
candidate = _normalize_path(path_str)
|
|
620
924
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
# Check if the resolved path is within allowed directories
|
|
628
|
-
try:
|
|
629
|
-
resolved_path.relative_to(base_dir)
|
|
630
|
-
return resolved_path
|
|
631
|
-
except ValueError:
|
|
632
|
-
pass
|
|
925
|
+
trusted_bases = [base_dir, Path(tempfile.gettempdir()).resolve()]
|
|
926
|
+
for base in trusted_bases:
|
|
927
|
+
try:
|
|
928
|
+
return _ensure_within_base_path(candidate, base)
|
|
929
|
+
except ValueError:
|
|
930
|
+
continue
|
|
633
931
|
|
|
634
|
-
|
|
635
|
-
resolved_path.relative_to(temp_dir)
|
|
636
|
-
return resolved_path
|
|
637
|
-
except ValueError:
|
|
638
|
-
st.error(
|
|
639
|
-
"❌ Invalid path: The resolved path is outside the allowed "
|
|
640
|
-
"directories (workspace or temporary directory). Paths cannot go above "
|
|
641
|
-
"the workspace root for security reasons."
|
|
642
|
-
)
|
|
643
|
-
return None
|
|
932
|
+
raise ValueError(f"Path traversal attempt: escapes {base_dir}")
|
|
644
933
|
|
|
645
|
-
except
|
|
646
|
-
st.error(f"
|
|
934
|
+
except ValueError as exc:
|
|
935
|
+
st.error(f"Invalid path: {exc}")
|
|
647
936
|
return None
|
|
648
937
|
|
|
649
938
|
|
|
@@ -760,150 +1049,1328 @@ def _create_no_metadata_entry(cookbook):
|
|
|
760
1049
|
def _display_cookbook_table(cookbook_data):
|
|
761
1050
|
"""Display the cookbook data in a table."""
|
|
762
1051
|
df = pd.DataFrame(cookbook_data)
|
|
763
|
-
st.dataframe(df,
|
|
1052
|
+
st.dataframe(df, width="stretch")
|
|
764
1053
|
|
|
765
1054
|
|
|
766
1055
|
def _handle_cookbook_selection(cookbook_path: str, cookbook_data: list):
|
|
767
|
-
"""Handle selection
|
|
768
|
-
st.subheader("
|
|
1056
|
+
"""Handle the cookbook selection interface with individual and holistic options."""
|
|
1057
|
+
st.subheader("Cookbook Selection & Analysis")
|
|
769
1058
|
|
|
770
|
-
#
|
|
771
|
-
|
|
772
|
-
selected_cookbooks = st.multiselect(
|
|
773
|
-
"Choose cookbooks to analyse:",
|
|
774
|
-
options=cookbook_names,
|
|
775
|
-
default=[], # No default selection
|
|
776
|
-
help="Select one or more cookbooks to analyse for migration to Ansible",
|
|
777
|
-
)
|
|
1059
|
+
# Show validation warnings if any cookbooks have issues
|
|
1060
|
+
_show_cookbook_validation_warnings(cookbook_data)
|
|
778
1061
|
|
|
779
|
-
#
|
|
780
|
-
|
|
781
|
-
|
|
1062
|
+
# Holistic analysis/conversion buttons
|
|
1063
|
+
st.markdown("### Holistic Analysis & Conversion")
|
|
1064
|
+
st.markdown(
|
|
1065
|
+
"Analyse and convert **ALL cookbooks** in the archive holistically, "
|
|
1066
|
+
"considering dependencies between cookbooks."
|
|
1067
|
+
)
|
|
782
1068
|
|
|
783
|
-
|
|
784
|
-
if st.button("Analyse Selected Cookbooks", type="primary"):
|
|
785
|
-
analyse_selected_cookbooks(cookbook_path, selected_cookbooks)
|
|
786
|
-
else:
|
|
787
|
-
st.info("Please select at least one cookbook to analyse")
|
|
1069
|
+
col1, col2 = st.columns(2)
|
|
788
1070
|
|
|
1071
|
+
with col1:
|
|
1072
|
+
if st.button(
|
|
1073
|
+
"🔍 Analyse ALL Cookbooks",
|
|
1074
|
+
type="primary",
|
|
1075
|
+
help="Analyse all cookbooks together considering inter-cookbook "
|
|
1076
|
+
"dependencies",
|
|
1077
|
+
key="holistic_analysis",
|
|
1078
|
+
):
|
|
1079
|
+
_analyze_all_cookbooks_holistically(cookbook_path, cookbook_data)
|
|
789
1080
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1081
|
+
with col2:
|
|
1082
|
+
if st.button(
|
|
1083
|
+
"🔄 Convert ALL Cookbooks",
|
|
1084
|
+
type="secondary",
|
|
1085
|
+
help="Convert all cookbooks to Ansible roles considering dependencies",
|
|
1086
|
+
key="holistic_conversion",
|
|
1087
|
+
):
|
|
1088
|
+
_convert_all_cookbooks_holistically(cookbook_path)
|
|
795
1089
|
|
|
796
|
-
|
|
797
|
-
class MockUploadedFile:
|
|
798
|
-
def __init__(self, data, name, mime_type):
|
|
799
|
-
self.data = data
|
|
800
|
-
self.name = name
|
|
801
|
-
self.type = mime_type
|
|
1090
|
+
st.divider()
|
|
802
1091
|
|
|
803
|
-
|
|
804
|
-
|
|
1092
|
+
# Individual cookbook selection
|
|
1093
|
+
st.markdown("### Individual Cookbook Selection")
|
|
1094
|
+
st.markdown("Select specific cookbooks to analyse individually.")
|
|
805
1095
|
|
|
806
|
-
|
|
807
|
-
|
|
1096
|
+
# Get list of cookbook names for multiselect
|
|
1097
|
+
cookbook_names = [cb["Name"] for cb in cookbook_data]
|
|
808
1098
|
|
|
809
|
-
|
|
810
|
-
|
|
1099
|
+
selected_cookbooks = st.multiselect(
|
|
1100
|
+
"Select cookbooks to analyse:",
|
|
1101
|
+
options=cookbook_names,
|
|
1102
|
+
default=[],
|
|
1103
|
+
help="Choose which cookbooks to analyse individually",
|
|
811
1104
|
)
|
|
812
1105
|
|
|
813
|
-
|
|
814
|
-
|
|
1106
|
+
if selected_cookbooks:
|
|
1107
|
+
col1, col2, col3 = st.columns(3)
|
|
815
1108
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
del st.session_state.uploaded_file_data
|
|
824
|
-
del st.session_state.uploaded_file_name
|
|
825
|
-
del st.session_state.uploaded_file_type
|
|
826
|
-
st.rerun()
|
|
1109
|
+
with col1:
|
|
1110
|
+
if st.button(
|
|
1111
|
+
f"📊 Analyse Selected ({len(selected_cookbooks)})",
|
|
1112
|
+
help=f"Analyse {len(selected_cookbooks)} selected cookbooks",
|
|
1113
|
+
key="analyze_selected",
|
|
1114
|
+
):
|
|
1115
|
+
analyse_selected_cookbooks(cookbook_path, selected_cookbooks)
|
|
827
1116
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1117
|
+
with col2:
|
|
1118
|
+
if st.button(
|
|
1119
|
+
f"🔗 Analyse as Project ({len(selected_cookbooks)})",
|
|
1120
|
+
help=f"Analyse {len(selected_cookbooks)} cookbooks as a project "
|
|
1121
|
+
f"with dependency analysis",
|
|
1122
|
+
key="analyze_project",
|
|
1123
|
+
):
|
|
1124
|
+
analyse_project_cookbooks(cookbook_path, selected_cookbooks)
|
|
1125
|
+
|
|
1126
|
+
with col3:
|
|
1127
|
+
if st.button(
|
|
1128
|
+
f"Select All ({len(cookbook_names)})",
|
|
1129
|
+
help=f"Select all {len(cookbook_names)} cookbooks",
|
|
1130
|
+
key="select_all",
|
|
1131
|
+
):
|
|
1132
|
+
# This will trigger a rerun with all cookbooks selected
|
|
1133
|
+
st.session_state.selected_cookbooks = cookbook_names
|
|
1134
|
+
st.rerun()
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _show_cookbook_validation_warnings(cookbook_data: list):
|
|
1138
|
+
"""Show validation warnings for cookbooks that might not be analyzable."""
|
|
1139
|
+
problematic_cookbooks = []
|
|
1140
|
+
|
|
1141
|
+
for cookbook in cookbook_data:
|
|
1142
|
+
if cookbook.get(METADATA_COLUMN_NAME) == METADATA_STATUS_NO:
|
|
1143
|
+
problematic_cookbooks.append(cookbook["Name"])
|
|
1144
|
+
|
|
1145
|
+
if problematic_cookbooks:
|
|
1146
|
+
st.warning("Some cookbooks may not be analyzable:")
|
|
1147
|
+
st.markdown("**Cookbooks without valid metadata.rb:**")
|
|
1148
|
+
for name in problematic_cookbooks:
|
|
1149
|
+
st.write(f"• {name}")
|
|
1150
|
+
|
|
1151
|
+
with st.expander("Why this matters"):
|
|
1152
|
+
st.markdown("""
|
|
1153
|
+
Cookbooks need a valid `metadata.rb` file for proper analysis. Without it:
|
|
1154
|
+
- Version and maintainer information cannot be determined
|
|
1155
|
+
- Dependencies cannot be identified
|
|
1156
|
+
- Analysis may fail or produce incomplete results
|
|
1157
|
+
|
|
1158
|
+
**To fix:** Ensure each cookbook has a `metadata.rb` file with
|
|
1159
|
+
proper Ruby syntax.
|
|
1160
|
+
""")
|
|
1161
|
+
|
|
1162
|
+
# Check for cookbooks without recipes
|
|
1163
|
+
cookbooks_without_recipes = []
|
|
1164
|
+
for cookbook in cookbook_data:
|
|
1165
|
+
cookbook_dir = _normalize_path(cookbook["Path"])
|
|
1166
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
1167
|
+
if not recipes_dir.exists() or not list(recipes_dir.glob("*.rb")):
|
|
1168
|
+
cookbooks_without_recipes.append(cookbook["Name"])
|
|
1169
|
+
|
|
1170
|
+
if cookbooks_without_recipes:
|
|
1171
|
+
st.warning("Some cookbooks may not have recipes:")
|
|
1172
|
+
st.markdown("**Cookbooks without recipe files:**")
|
|
1173
|
+
for name in cookbooks_without_recipes:
|
|
1174
|
+
st.write(f"• {name}")
|
|
1175
|
+
|
|
1176
|
+
with st.expander("Why this matters"):
|
|
1177
|
+
st.markdown("""
|
|
1178
|
+
Cookbooks need recipe files (`.rb` files in the `recipes/` directory)
|
|
1179
|
+
to be converted to Ansible.
|
|
1180
|
+
Without recipes, the cookbook cannot be analyzed or converted.
|
|
1181
|
+
|
|
1182
|
+
**To fix:** Ensure each cookbook has at least one `.rb` file in its
|
|
1183
|
+
`recipes/` directory.
|
|
1184
|
+
""")
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _analyze_all_cookbooks_holistically(
|
|
1188
|
+
cookbook_path: str, cookbook_data: list
|
|
1189
|
+
) -> None:
|
|
1190
|
+
"""Analyse all cookbooks holistically."""
|
|
1191
|
+
st.subheader("Holistic Cookbook Analysis")
|
|
1192
|
+
|
|
1193
|
+
progress_bar, status_text = _setup_analysis_progress()
|
|
832
1194
|
|
|
833
|
-
# Process the file
|
|
834
1195
|
try:
|
|
835
|
-
|
|
836
|
-
temp_dir, cookbook_path = extract_archive(mock_file)
|
|
837
|
-
# Store temp_dir in session state to prevent premature cleanup
|
|
838
|
-
st.session_state.temp_dir = temp_dir
|
|
839
|
-
st.success("Archive extracted successfully!")
|
|
1196
|
+
status_text.text("Performing holistic analysis of all cookbooks...")
|
|
840
1197
|
|
|
841
|
-
#
|
|
842
|
-
|
|
843
|
-
|
|
1198
|
+
# Check if AI-enhanced analysis is available
|
|
1199
|
+
ai_config = load_ai_settings()
|
|
1200
|
+
provider_name = _get_ai_provider(ai_config)
|
|
1201
|
+
use_ai = (
|
|
1202
|
+
provider_name
|
|
1203
|
+
and provider_name != LOCAL_PROVIDER
|
|
1204
|
+
and ai_config.get("api_key")
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
if use_ai:
|
|
1208
|
+
results = _analyze_with_ai(cookbook_data, provider_name, progress_bar)
|
|
1209
|
+
assessment_result = {
|
|
1210
|
+
"cookbook_assessments": results,
|
|
1211
|
+
"recommendations": "AI-enhanced per-cookbook recommendations above",
|
|
1212
|
+
}
|
|
1213
|
+
st.session_state.analysis_info_messages = [
|
|
1214
|
+
f"Using AI-enhanced analysis with {provider_name} "
|
|
1215
|
+
f"({_get_ai_string_value(ai_config, 'model', 'claude-3-5-sonnet-20241022')})", # noqa: E501
|
|
1216
|
+
f"Detected {len(cookbook_data)} cookbook(s)",
|
|
1217
|
+
]
|
|
1218
|
+
else:
|
|
1219
|
+
results, assessment_result = _analyze_rule_based(cookbook_data)
|
|
1220
|
+
|
|
1221
|
+
st.session_state.holistic_assessment = assessment_result
|
|
1222
|
+
st.session_state.analysis_results = results
|
|
1223
|
+
st.session_state.analysis_cookbook_path = cookbook_path
|
|
1224
|
+
st.session_state.total_cookbooks = len(results)
|
|
1225
|
+
|
|
1226
|
+
progress_bar.progress(1.0)
|
|
1227
|
+
st.rerun()
|
|
844
1228
|
|
|
845
1229
|
except Exception as e:
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1230
|
+
progress_bar.empty()
|
|
1231
|
+
status_text.empty()
|
|
1232
|
+
st.error(f"Holistic analysis failed: {e}")
|
|
1233
|
+
finally:
|
|
1234
|
+
progress_bar.empty()
|
|
1235
|
+
status_text.empty()
|
|
852
1236
|
|
|
853
1237
|
|
|
854
|
-
def
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1238
|
+
def _analyze_with_ai(
|
|
1239
|
+
cookbook_data: list,
|
|
1240
|
+
provider_name: str,
|
|
1241
|
+
progress_bar,
|
|
1242
|
+
) -> list:
|
|
1243
|
+
"""
|
|
1244
|
+
Analyze cookbooks using AI-enhanced analysis.
|
|
859
1245
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
3. **Select Cookbooks**: Choose which cookbooks to analyse
|
|
865
|
-
4. **Run Analysis**: Click "Analyse Selected Cookbooks" to get detailed insights
|
|
1246
|
+
Args:
|
|
1247
|
+
cookbook_data: List of cookbook data.
|
|
1248
|
+
provider_name: Name of the AI provider.
|
|
1249
|
+
progress_bar: Streamlit progress bar.
|
|
866
1250
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
- `../shared/cookbooks/` - parent directory
|
|
870
|
-
- `./my-cookbooks/` - explicit current directory
|
|
1251
|
+
Returns:
|
|
1252
|
+
List of analysis results.
|
|
871
1253
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1254
|
+
"""
|
|
1255
|
+
from souschef.assessment import assess_single_cookbook_with_ai
|
|
1256
|
+
|
|
1257
|
+
ai_config = load_ai_settings()
|
|
1258
|
+
provider_mapping = {
|
|
1259
|
+
ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
|
|
1260
|
+
ANTHROPIC_PROVIDER: "anthropic",
|
|
1261
|
+
"OpenAI": "openai",
|
|
1262
|
+
OPENAI_PROVIDER: "openai",
|
|
1263
|
+
IBM_WATSONX: "watson",
|
|
1264
|
+
RED_HAT_LIGHTSPEED: "lightspeed",
|
|
1265
|
+
}
|
|
1266
|
+
provider = provider_mapping.get(
|
|
1267
|
+
provider_name,
|
|
1268
|
+
provider_name.lower().replace(" ", "_"),
|
|
1269
|
+
)
|
|
875
1270
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1271
|
+
model = _get_ai_string_value(ai_config, "model", "claude-3-5-sonnet-20241022")
|
|
1272
|
+
api_key = _get_ai_string_value(ai_config, "api_key", "")
|
|
1273
|
+
temperature = _get_ai_float_value(ai_config, "temperature", 0.7)
|
|
1274
|
+
max_tokens = _get_ai_int_value(ai_config, "max_tokens", 4000)
|
|
1275
|
+
project_id = _get_ai_string_value(ai_config, "project_id", "")
|
|
1276
|
+
base_url = _get_ai_string_value(ai_config, "base_url", "")
|
|
879
1277
|
|
|
1278
|
+
st.info(f"Using AI-enhanced analysis with {provider_name} ({model})")
|
|
880
1279
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
│ └── attributes/
|
|
888
|
-
├── apache2/
|
|
889
|
-
│ └── metadata.rb
|
|
890
|
-
└── mysql/
|
|
891
|
-
└── metadata.rb
|
|
892
|
-
```
|
|
1280
|
+
# Count total recipes across all cookbooks
|
|
1281
|
+
def _safe_count_recipes(path_str: str) -> int:
|
|
1282
|
+
"""Count recipes safely with CodeQL-recognized containment checks."""
|
|
1283
|
+
try:
|
|
1284
|
+
normalized = _normalize_path(path_str)
|
|
1285
|
+
recipes_dir = normalized / "recipes"
|
|
893
1286
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1287
|
+
if recipes_dir.exists():
|
|
1288
|
+
return len(list(recipes_dir.glob("*.rb")))
|
|
1289
|
+
return 0
|
|
1290
|
+
except (ValueError, OSError):
|
|
1291
|
+
return 0
|
|
899
1292
|
|
|
1293
|
+
total_recipes = sum(_safe_count_recipes(cb["Path"]) for cb in cookbook_data)
|
|
900
1294
|
|
|
901
|
-
|
|
902
|
-
"""Analyse the selected cookbooks and store results in session state."""
|
|
903
|
-
st.subheader("Analysis Results")
|
|
1295
|
+
st.info(f"Detected {len(cookbook_data)} cookbook(s) with {total_recipes} recipe(s)")
|
|
904
1296
|
|
|
905
|
-
|
|
906
|
-
|
|
1297
|
+
results = []
|
|
1298
|
+
for i, cb_data in enumerate(cookbook_data):
|
|
1299
|
+
# Count recipes in this cookbook
|
|
1300
|
+
recipe_count = _safe_count_recipes(cb_data["Path"])
|
|
1301
|
+
|
|
1302
|
+
st.info(
|
|
1303
|
+
f"Analyzing {cb_data['Name']} ({recipe_count} recipes)... "
|
|
1304
|
+
f"({i + 1}/{len(cookbook_data)})"
|
|
1305
|
+
)
|
|
1306
|
+
progress_bar.progress((i + 1) / len(cookbook_data))
|
|
1307
|
+
|
|
1308
|
+
assessment = assess_single_cookbook_with_ai(
|
|
1309
|
+
cb_data["Path"],
|
|
1310
|
+
ai_provider=provider,
|
|
1311
|
+
api_key=api_key,
|
|
1312
|
+
model=model,
|
|
1313
|
+
temperature=temperature,
|
|
1314
|
+
max_tokens=max_tokens,
|
|
1315
|
+
project_id=project_id,
|
|
1316
|
+
base_url=base_url,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
result = _build_cookbook_result(cb_data, assessment, ANALYSIS_STATUS_ANALYSED)
|
|
1320
|
+
results.append(result)
|
|
1321
|
+
|
|
1322
|
+
return results
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _analyze_rule_based(
|
|
1326
|
+
cookbook_data: list,
|
|
1327
|
+
) -> tuple[list, dict]:
|
|
1328
|
+
"""
|
|
1329
|
+
Analyze cookbooks using rule-based analysis.
|
|
1330
|
+
|
|
1331
|
+
Args:
|
|
1332
|
+
cookbook_data: List of cookbook data.
|
|
1333
|
+
|
|
1334
|
+
Returns:
|
|
1335
|
+
Tuple of (results list, assessment_result dict).
|
|
1336
|
+
|
|
1337
|
+
"""
|
|
1338
|
+
from souschef.assessment import parse_chef_migration_assessment
|
|
1339
|
+
|
|
1340
|
+
cookbook_paths_list = [cb["Path"] for cb in cookbook_data]
|
|
1341
|
+
cookbook_paths_str = ",".join(cookbook_paths_list)
|
|
1342
|
+
|
|
1343
|
+
assessment_result = parse_chef_migration_assessment(cookbook_paths_str)
|
|
1344
|
+
|
|
1345
|
+
if "error" in assessment_result:
|
|
1346
|
+
st.error(f"Holistic analysis failed: {assessment_result['error']}")
|
|
1347
|
+
return [], {}
|
|
1348
|
+
|
|
1349
|
+
results = _process_cookbook_assessments(assessment_result, cookbook_data)
|
|
1350
|
+
return results, assessment_result
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _process_cookbook_assessments(assessment_result: dict, cookbook_data: list) -> list:
|
|
1354
|
+
"""
|
|
1355
|
+
Process cookbook assessments and build results.
|
|
1356
|
+
|
|
1357
|
+
Args:
|
|
1358
|
+
assessment_result: Assessment result dictionary.
|
|
1359
|
+
cookbook_data: List of cookbook data.
|
|
1360
|
+
|
|
1361
|
+
Returns:
|
|
1362
|
+
List of result dictionaries.
|
|
1363
|
+
|
|
1364
|
+
"""
|
|
1365
|
+
results: list[dict] = []
|
|
1366
|
+
if "cookbook_assessments" not in assessment_result:
|
|
1367
|
+
return results
|
|
1368
|
+
|
|
1369
|
+
top_recommendations = assessment_result.get("recommendations", "")
|
|
1370
|
+
|
|
1371
|
+
for cookbook_assessment in assessment_result["cookbook_assessments"]:
|
|
1372
|
+
result = _build_assessment_result(
|
|
1373
|
+
cookbook_assessment, cookbook_data, top_recommendations
|
|
1374
|
+
)
|
|
1375
|
+
results.append(result)
|
|
1376
|
+
|
|
1377
|
+
return results
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _build_assessment_result(
|
|
1381
|
+
cookbook_assessment: dict, cookbook_data: list, top_recommendations: str
|
|
1382
|
+
) -> dict:
|
|
1383
|
+
"""
|
|
1384
|
+
Build result dictionary from cookbook assessment.
|
|
1385
|
+
|
|
1386
|
+
Args:
|
|
1387
|
+
cookbook_assessment: Single cookbook assessment.
|
|
1388
|
+
cookbook_data: List of cookbook data.
|
|
1389
|
+
top_recommendations: Top-level recommendations.
|
|
1390
|
+
|
|
1391
|
+
Returns:
|
|
1392
|
+
Result dictionary.
|
|
1393
|
+
|
|
1394
|
+
"""
|
|
1395
|
+
cookbook_path = cookbook_assessment.get("cookbook_path", "")
|
|
1396
|
+
cookbook_info = _find_cookbook_info(cookbook_data, cookbook_path)
|
|
1397
|
+
|
|
1398
|
+
recommendations = _build_recommendations(cookbook_assessment, top_recommendations)
|
|
1399
|
+
|
|
1400
|
+
estimated_days = cookbook_assessment.get("estimated_effort_days", 0)
|
|
1401
|
+
effort_metrics = EffortMetrics(estimated_days)
|
|
1402
|
+
|
|
1403
|
+
return {
|
|
1404
|
+
"name": (
|
|
1405
|
+
cookbook_info["Name"]
|
|
1406
|
+
if cookbook_info
|
|
1407
|
+
else cookbook_assessment["cookbook_name"]
|
|
1408
|
+
),
|
|
1409
|
+
"path": cookbook_info["Path"] if cookbook_info else cookbook_path,
|
|
1410
|
+
"version": cookbook_info["Version"] if cookbook_info else "Unknown",
|
|
1411
|
+
"maintainer": cookbook_info["Maintainer"] if cookbook_info else "Unknown",
|
|
1412
|
+
"description": (
|
|
1413
|
+
cookbook_info["Description"] if cookbook_info else "Analysed holistically"
|
|
1414
|
+
),
|
|
1415
|
+
"dependencies": int(cookbook_assessment.get("dependencies", 0) or 0),
|
|
1416
|
+
"complexity": cookbook_assessment.get("migration_priority", "Unknown").title(),
|
|
1417
|
+
"estimated_hours": effort_metrics.estimated_hours,
|
|
1418
|
+
"recommendations": recommendations,
|
|
1419
|
+
"status": ANALYSIS_STATUS_ANALYSED,
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _find_cookbook_info(cookbook_data: list, cookbook_path: str) -> dict | None:
|
|
1424
|
+
"""
|
|
1425
|
+
Find cookbook info matching the given path.
|
|
1426
|
+
|
|
1427
|
+
Args:
|
|
1428
|
+
cookbook_data: List of cookbook data.
|
|
1429
|
+
cookbook_path: Path to match.
|
|
1430
|
+
|
|
1431
|
+
Returns:
|
|
1432
|
+
Matching cookbook info or None.
|
|
1433
|
+
|
|
1434
|
+
"""
|
|
1435
|
+
return next(
|
|
1436
|
+
(cd for cd in cookbook_data if cd["Path"] == cookbook_path),
|
|
1437
|
+
None,
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _build_cookbook_result(cb_data: dict, assessment: dict, status: str) -> dict:
|
|
1442
|
+
"""
|
|
1443
|
+
Build a cookbook result from assessment data.
|
|
1444
|
+
|
|
1445
|
+
Args:
|
|
1446
|
+
cb_data: Cookbook data.
|
|
1447
|
+
assessment: Assessment dictionary.
|
|
1448
|
+
status: Status of analysis.
|
|
1449
|
+
|
|
1450
|
+
Returns:
|
|
1451
|
+
Result dictionary.
|
|
1452
|
+
|
|
1453
|
+
"""
|
|
1454
|
+
if "error" not in assessment:
|
|
1455
|
+
return {
|
|
1456
|
+
"name": cb_data["Name"],
|
|
1457
|
+
"path": cb_data["Path"],
|
|
1458
|
+
"version": cb_data["Version"],
|
|
1459
|
+
"maintainer": cb_data["Maintainer"],
|
|
1460
|
+
"description": cb_data["Description"],
|
|
1461
|
+
"dependencies": cb_data["Dependencies"],
|
|
1462
|
+
"complexity": assessment.get("complexity", "Unknown"),
|
|
1463
|
+
"estimated_hours": assessment.get("estimated_hours", 0),
|
|
1464
|
+
"recommendations": assessment.get(
|
|
1465
|
+
"recommendations", "No recommendations available"
|
|
1466
|
+
),
|
|
1467
|
+
"status": status,
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
"name": cb_data["Name"],
|
|
1471
|
+
"path": cb_data["Path"],
|
|
1472
|
+
"version": cb_data["Version"],
|
|
1473
|
+
"maintainer": cb_data["Maintainer"],
|
|
1474
|
+
"description": cb_data["Description"],
|
|
1475
|
+
"dependencies": cb_data["Dependencies"],
|
|
1476
|
+
"complexity": "Error",
|
|
1477
|
+
"estimated_hours": 0,
|
|
1478
|
+
"recommendations": f"Analysis failed: {assessment['error']}",
|
|
1479
|
+
"status": ANALYSIS_STATUS_FAILED,
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _build_recommendations(cookbook_assessment: dict, top_recommendations: str) -> str:
|
|
1484
|
+
"""
|
|
1485
|
+
Build recommendations from cookbook assessment.
|
|
1486
|
+
|
|
1487
|
+
Args:
|
|
1488
|
+
cookbook_assessment: Assessment data for a cookbook.
|
|
1489
|
+
top_recommendations: Top-level recommendations.
|
|
1490
|
+
|
|
1491
|
+
Returns:
|
|
1492
|
+
Formatted recommendations string.
|
|
1493
|
+
|
|
1494
|
+
"""
|
|
1495
|
+
recommendations: list[str] = []
|
|
1496
|
+
if cookbook_assessment.get("challenges"):
|
|
1497
|
+
for challenge in cookbook_assessment["challenges"]:
|
|
1498
|
+
recommendations.append(f"• {challenge}")
|
|
1499
|
+
return "\n".join(recommendations)
|
|
1500
|
+
|
|
1501
|
+
return (
|
|
1502
|
+
top_recommendations
|
|
1503
|
+
if top_recommendations
|
|
1504
|
+
else f"Complexity: {str(cookbook_assessment.get('complexity_score', 0))}/100"
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _convert_all_cookbooks_holistically(cookbook_path: str):
|
|
1509
|
+
"""Convert all cookbooks to Ansible roles."""
|
|
1510
|
+
st.subheader("Holistic Cookbook Conversion")
|
|
1511
|
+
|
|
1512
|
+
progress_bar, status_text = _setup_analysis_progress()
|
|
1513
|
+
|
|
1514
|
+
try:
|
|
1515
|
+
status_text.text("Converting all cookbooks holistically...")
|
|
1516
|
+
|
|
1517
|
+
# Create temporary output directory with secure permissions
|
|
1518
|
+
import tempfile
|
|
1519
|
+
from pathlib import Path
|
|
1520
|
+
|
|
1521
|
+
output_dir = Path(tempfile.mkdtemp(prefix="souschef_holistic_conversion_"))
|
|
1522
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
1523
|
+
output_dir.chmod(0o700) # Secure permissions: rwx------
|
|
1524
|
+
|
|
1525
|
+
# Get assessment data if available
|
|
1526
|
+
assessment_data = ""
|
|
1527
|
+
if (
|
|
1528
|
+
"holistic_assessment" in st.session_state
|
|
1529
|
+
and st.session_state.holistic_assessment
|
|
1530
|
+
):
|
|
1531
|
+
assessment_data = json.dumps(st.session_state.holistic_assessment)
|
|
1532
|
+
|
|
1533
|
+
# Call the new holistic conversion function
|
|
1534
|
+
from souschef.server import convert_all_cookbooks_comprehensive
|
|
1535
|
+
|
|
1536
|
+
conversion_result = convert_all_cookbooks_comprehensive(
|
|
1537
|
+
cookbooks_path=cookbook_path,
|
|
1538
|
+
output_path=str(output_dir),
|
|
1539
|
+
assessment_data=assessment_data,
|
|
1540
|
+
include_templates=True,
|
|
1541
|
+
include_attributes=True,
|
|
1542
|
+
include_recipes=True,
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
if conversion_result.startswith("Error"):
|
|
1546
|
+
st.error(f"Holistic conversion failed: {conversion_result}")
|
|
1547
|
+
return
|
|
1548
|
+
|
|
1549
|
+
# Store conversion result for display
|
|
1550
|
+
st.session_state.holistic_conversion_result = {
|
|
1551
|
+
"result": conversion_result,
|
|
1552
|
+
"output_path": str(output_dir),
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
progress_bar.progress(1.0)
|
|
1556
|
+
status_text.text("Holistic conversion completed!")
|
|
1557
|
+
st.success("Holistically converted all cookbooks to Ansible roles!")
|
|
1558
|
+
|
|
1559
|
+
# Display conversion results
|
|
1560
|
+
_display_holistic_conversion_results(
|
|
1561
|
+
st.session_state.holistic_conversion_result
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# Trigger rerun to display results
|
|
1565
|
+
st.rerun()
|
|
1566
|
+
|
|
1567
|
+
except Exception as e:
|
|
1568
|
+
progress_bar.empty()
|
|
1569
|
+
status_text.empty()
|
|
1570
|
+
st.error(f"Holistic conversion failed: {e}")
|
|
1571
|
+
finally:
|
|
1572
|
+
progress_bar.empty()
|
|
1573
|
+
status_text.empty()
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _parse_conversion_result_text(result_text: str) -> dict:
|
|
1577
|
+
"""Parse the conversion result text to extract structured data."""
|
|
1578
|
+
structured: dict[str, Any] = {
|
|
1579
|
+
"summary": {},
|
|
1580
|
+
"cookbook_results": [],
|
|
1581
|
+
"warnings": [],
|
|
1582
|
+
"errors": [],
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
lines = result_text.split("\n")
|
|
1586
|
+
current_section = None
|
|
1587
|
+
|
|
1588
|
+
for line in lines:
|
|
1589
|
+
line = line.strip()
|
|
1590
|
+
|
|
1591
|
+
# Parse summary section
|
|
1592
|
+
if "## Overview:" in line:
|
|
1593
|
+
current_section = "summary"
|
|
1594
|
+
elif current_section == "summary" and "- " in line:
|
|
1595
|
+
_parse_summary_line(line, structured)
|
|
1596
|
+
|
|
1597
|
+
# Parse successfully converted cookbooks
|
|
1598
|
+
elif "## Successfully Converted Cookbooks:" in line:
|
|
1599
|
+
current_section = "converted"
|
|
1600
|
+
elif current_section == "converted" and line.startswith("- **"):
|
|
1601
|
+
_parse_converted_cookbook(line, structured)
|
|
1602
|
+
|
|
1603
|
+
# Parse failed conversions
|
|
1604
|
+
elif "## Failed Conversions:" in line:
|
|
1605
|
+
current_section = "failed"
|
|
1606
|
+
elif current_section == "failed" and line.startswith("- ❌ **"):
|
|
1607
|
+
_parse_failed_cookbook(line, structured)
|
|
1608
|
+
|
|
1609
|
+
# Extract warnings from the result text
|
|
1610
|
+
_extract_warnings_from_text(result_text, structured)
|
|
1611
|
+
|
|
1612
|
+
return structured
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _parse_summary_line(line: str, structured: dict):
|
|
1616
|
+
"""Parse a single summary line."""
|
|
1617
|
+
if "Total cookbooks found:" in line:
|
|
1618
|
+
try:
|
|
1619
|
+
count = int(line.split(":")[-1].strip())
|
|
1620
|
+
structured["summary"]["total_cookbooks"] = count
|
|
1621
|
+
except ValueError as err:
|
|
1622
|
+
structured.setdefault("parse_errors", []).append(
|
|
1623
|
+
f"total_cookbooks_parse_failed: {err}"
|
|
1624
|
+
)
|
|
1625
|
+
elif "Successfully converted:" in line:
|
|
1626
|
+
try:
|
|
1627
|
+
count = int(line.split(":")[-1].strip())
|
|
1628
|
+
structured["summary"]["cookbooks_converted"] = count
|
|
1629
|
+
except ValueError as err:
|
|
1630
|
+
structured.setdefault("parse_errors", []).append(
|
|
1631
|
+
f"cookbooks_converted_parse_failed: {err}"
|
|
1632
|
+
)
|
|
1633
|
+
elif "Total files converted:" in line:
|
|
1634
|
+
try:
|
|
1635
|
+
count = int(line.split(":")[-1].strip())
|
|
1636
|
+
structured["summary"]["total_converted_files"] = count
|
|
1637
|
+
except ValueError as err:
|
|
1638
|
+
structured.setdefault("parse_errors", []).append(
|
|
1639
|
+
f"total_converted_files_parse_failed: {err}"
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _parse_converted_cookbook(line: str, structured: dict):
|
|
1644
|
+
"""Parse a successfully converted cookbook line."""
|
|
1645
|
+
try:
|
|
1646
|
+
parts = line.split("**")
|
|
1647
|
+
if len(parts) >= 3:
|
|
1648
|
+
cookbook_name = parts[1]
|
|
1649
|
+
role_name = parts[3].strip("`→ ")
|
|
1650
|
+
structured["cookbook_results"].append(
|
|
1651
|
+
{
|
|
1652
|
+
"cookbook_name": cookbook_name,
|
|
1653
|
+
"role_name": role_name,
|
|
1654
|
+
"status": "success",
|
|
1655
|
+
"tasks_count": 0, # Will be updated if more details available
|
|
1656
|
+
"templates_count": 0,
|
|
1657
|
+
"variables_count": 0,
|
|
1658
|
+
"files_count": 0,
|
|
1659
|
+
}
|
|
1660
|
+
)
|
|
1661
|
+
except (IndexError, ValueError) as err:
|
|
1662
|
+
structured.setdefault("parse_errors", []).append(
|
|
1663
|
+
f"converted_cookbook_parse_failed: {err}"
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _parse_failed_cookbook(line: str, structured: dict):
|
|
1668
|
+
"""Parse a failed conversion cookbook line."""
|
|
1669
|
+
try:
|
|
1670
|
+
parts = line.split("**")
|
|
1671
|
+
if len(parts) >= 3:
|
|
1672
|
+
cookbook_name = parts[1]
|
|
1673
|
+
error = parts[3].strip(": ")
|
|
1674
|
+
structured["cookbook_results"].append(
|
|
1675
|
+
{
|
|
1676
|
+
"cookbook_name": cookbook_name,
|
|
1677
|
+
"status": "failed",
|
|
1678
|
+
"error": error,
|
|
1679
|
+
}
|
|
1680
|
+
)
|
|
1681
|
+
except (IndexError, ValueError) as err:
|
|
1682
|
+
structured.setdefault("parse_errors", []).append(
|
|
1683
|
+
f"failed_cookbook_parse_failed: {err}"
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _extract_warnings_from_text(result_text: str, structured: dict):
|
|
1688
|
+
"""Extract warnings from the conversion result text."""
|
|
1689
|
+
# Extract warnings from the result text (look for common warning patterns)
|
|
1690
|
+
if "No recipes directory found" in result_text:
|
|
1691
|
+
structured["warnings"].append(
|
|
1692
|
+
"Some cookbooks are missing recipes directories and cannot be "
|
|
1693
|
+
"converted to Ansible tasks"
|
|
1694
|
+
)
|
|
1695
|
+
if "No recipe files" in result_text.lower():
|
|
1696
|
+
structured["warnings"].append("Some cookbooks have empty recipes directories")
|
|
1697
|
+
|
|
1698
|
+
# If no cookbooks were successfully converted but some were found,
|
|
1699
|
+
# add a general warning
|
|
1700
|
+
total_found = structured["summary"].get("total_cookbooks", 0)
|
|
1701
|
+
converted = structured["summary"].get("cookbooks_converted", 0)
|
|
1702
|
+
if total_found > 0 and converted == 0:
|
|
1703
|
+
structured["warnings"].append(
|
|
1704
|
+
"No cookbooks were successfully converted. Check that cookbooks "
|
|
1705
|
+
"contain recipes directories with .rb files."
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _display_holistic_conversion_results(conversion_result: dict):
|
|
1710
|
+
"""Display the results of holistic cookbook conversion."""
|
|
1711
|
+
st.subheader("Holistic Conversion Results")
|
|
1712
|
+
|
|
1713
|
+
# Parse the conversion result string to extract structured data
|
|
1714
|
+
result_text = conversion_result.get("result", "")
|
|
1715
|
+
structured_result = _parse_conversion_result_text(result_text)
|
|
1716
|
+
|
|
1717
|
+
_display_conversion_summary(structured_result)
|
|
1718
|
+
_display_conversion_warnings_errors(structured_result)
|
|
1719
|
+
_display_conversion_details(structured_result)
|
|
1720
|
+
_display_conversion_report(result_text)
|
|
1721
|
+
_display_conversion_download_options(conversion_result)
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def _display_conversion_summary(structured_result: dict):
|
|
1725
|
+
"""Display the conversion summary metrics."""
|
|
1726
|
+
if "summary" in structured_result:
|
|
1727
|
+
summary = structured_result["summary"]
|
|
1728
|
+
col1, col2, col3, col4 = st.columns(4)
|
|
1729
|
+
|
|
1730
|
+
with col1:
|
|
1731
|
+
st.metric("Cookbooks Converted", summary.get("cookbooks_converted", 0))
|
|
1732
|
+
|
|
1733
|
+
with col2:
|
|
1734
|
+
st.metric("Roles Created", summary.get("roles_created", 0))
|
|
1735
|
+
|
|
1736
|
+
with col3:
|
|
1737
|
+
st.metric("Tasks Generated", summary.get("tasks_generated", 0))
|
|
1738
|
+
|
|
1739
|
+
with col4:
|
|
1740
|
+
st.metric("Templates Converted", summary.get("templates_converted", 0))
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def _display_conversion_warnings_errors(structured_result: dict):
|
|
1744
|
+
"""Display conversion warnings and errors."""
|
|
1745
|
+
if "warnings" in structured_result and structured_result["warnings"]:
|
|
1746
|
+
st.warning("Conversion Warnings")
|
|
1747
|
+
for warning in structured_result["warnings"]:
|
|
1748
|
+
st.write(f"• {warning}")
|
|
1749
|
+
|
|
1750
|
+
if "errors" in structured_result and structured_result["errors"]:
|
|
1751
|
+
st.error("❌ Conversion Errors")
|
|
1752
|
+
for error in structured_result["errors"]:
|
|
1753
|
+
st.write(f"• {error}")
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _display_conversion_details(structured_result: dict):
|
|
1757
|
+
"""Display detailed conversion results."""
|
|
1758
|
+
if "cookbook_results" in structured_result:
|
|
1759
|
+
st.subheader("Conversion Details")
|
|
1760
|
+
|
|
1761
|
+
for cookbook_result in structured_result["cookbook_results"]:
|
|
1762
|
+
with st.expander(
|
|
1763
|
+
f"Cookbook {cookbook_result.get('cookbook_name', 'Unknown')}",
|
|
1764
|
+
expanded=False,
|
|
1765
|
+
):
|
|
1766
|
+
col1, col2 = st.columns(2)
|
|
1767
|
+
|
|
1768
|
+
with col1:
|
|
1769
|
+
st.metric("Tasks", cookbook_result.get("tasks_count", 0))
|
|
1770
|
+
st.metric("Templates", cookbook_result.get("templates_count", 0))
|
|
1771
|
+
|
|
1772
|
+
with col2:
|
|
1773
|
+
st.metric("Variables", cookbook_result.get("variables_count", 0))
|
|
1774
|
+
st.metric("Files", cookbook_result.get("files_count", 0))
|
|
1775
|
+
|
|
1776
|
+
if cookbook_result.get("status") == "success":
|
|
1777
|
+
st.success("Conversion successful")
|
|
1778
|
+
else:
|
|
1779
|
+
error_msg = cookbook_result.get("error", "Unknown error")
|
|
1780
|
+
st.error(f"❌ Conversion failed: {error_msg}")
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _display_conversion_report(result_text: str):
|
|
1784
|
+
"""Display the raw conversion report."""
|
|
1785
|
+
with st.expander("Full Conversion Report"):
|
|
1786
|
+
st.code(result_text, language="markdown")
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _validate_output_path(output_path: str) -> Path | None:
|
|
1790
|
+
"""
|
|
1791
|
+
Validate and normalize output path.
|
|
1792
|
+
|
|
1793
|
+
Args:
|
|
1794
|
+
output_path: Path string to validate.
|
|
1795
|
+
|
|
1796
|
+
Returns:
|
|
1797
|
+
Normalized Path object or None if invalid.
|
|
1798
|
+
|
|
1799
|
+
"""
|
|
1800
|
+
try:
|
|
1801
|
+
safe_output_path = _normalize_path(str(output_path))
|
|
1802
|
+
base_dir = Path.cwd().resolve()
|
|
1803
|
+
# Use centralised containment validation
|
|
1804
|
+
validated = _ensure_within_base_path(safe_output_path, base_dir)
|
|
1805
|
+
return validated if validated.exists() else None
|
|
1806
|
+
except ValueError:
|
|
1807
|
+
return None
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _collect_role_files(safe_output_path: Path) -> list[tuple[Path, Path]]:
|
|
1811
|
+
"""
|
|
1812
|
+
Collect all files from converted roles directory.
|
|
1813
|
+
|
|
1814
|
+
Args:
|
|
1815
|
+
safe_output_path: Validated base path.
|
|
1816
|
+
|
|
1817
|
+
Returns:
|
|
1818
|
+
List of (file_path, archive_name) tuples.
|
|
1819
|
+
|
|
1820
|
+
"""
|
|
1821
|
+
files_to_archive = []
|
|
1822
|
+
# Path is already normalized; validate files within the output path are contained
|
|
1823
|
+
base_path = safe_output_path
|
|
1824
|
+
|
|
1825
|
+
for root, _dirs, files in os.walk(base_path):
|
|
1826
|
+
root_path = _ensure_within_base_path(Path(root), base_path)
|
|
1827
|
+
|
|
1828
|
+
for file in files:
|
|
1829
|
+
safe_name = _sanitize_filename(file)
|
|
1830
|
+
candidate_path = _ensure_within_base_path(root_path / safe_name, base_path)
|
|
1831
|
+
try:
|
|
1832
|
+
# Ensure each file is contained within base
|
|
1833
|
+
arcname = candidate_path.relative_to(base_path)
|
|
1834
|
+
files_to_archive.append((candidate_path, arcname))
|
|
1835
|
+
except ValueError:
|
|
1836
|
+
continue
|
|
1837
|
+
|
|
1838
|
+
return files_to_archive
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _create_roles_zip_archive(safe_output_path: Path) -> bytes:
|
|
1842
|
+
"""
|
|
1843
|
+
Create ZIP archive of converted roles.
|
|
1844
|
+
|
|
1845
|
+
Args:
|
|
1846
|
+
safe_output_path: Validated path containing roles.
|
|
1847
|
+
|
|
1848
|
+
Returns:
|
|
1849
|
+
ZIP archive as bytes.
|
|
1850
|
+
|
|
1851
|
+
"""
|
|
1852
|
+
zip_buffer = io.BytesIO()
|
|
1853
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
1854
|
+
files_to_archive = _collect_role_files(safe_output_path)
|
|
1855
|
+
for file_path, arcname in files_to_archive:
|
|
1856
|
+
zip_file.write(str(file_path), str(arcname))
|
|
1857
|
+
|
|
1858
|
+
zip_buffer.seek(0)
|
|
1859
|
+
return zip_buffer.getvalue()
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def _get_git_path() -> str:
|
|
1863
|
+
"""
|
|
1864
|
+
Find git executable in system PATH.
|
|
1865
|
+
|
|
1866
|
+
Returns:
|
|
1867
|
+
The path to git executable.
|
|
1868
|
+
|
|
1869
|
+
Raises:
|
|
1870
|
+
FileNotFoundError: If git is not found in PATH.
|
|
1871
|
+
|
|
1872
|
+
"""
|
|
1873
|
+
# Try common locations first
|
|
1874
|
+
common_paths = [
|
|
1875
|
+
"/usr/bin/git",
|
|
1876
|
+
"/usr/local/bin/git",
|
|
1877
|
+
"/opt/homebrew/bin/git",
|
|
1878
|
+
]
|
|
1879
|
+
|
|
1880
|
+
for path in common_paths:
|
|
1881
|
+
if Path(path).exists():
|
|
1882
|
+
return path
|
|
1883
|
+
|
|
1884
|
+
# Try to find git using 'which' command
|
|
1885
|
+
try:
|
|
1886
|
+
result = subprocess.run(
|
|
1887
|
+
["which", "git"],
|
|
1888
|
+
capture_output=True,
|
|
1889
|
+
text=True,
|
|
1890
|
+
check=True,
|
|
1891
|
+
timeout=5,
|
|
1892
|
+
)
|
|
1893
|
+
git_path = result.stdout.strip()
|
|
1894
|
+
if git_path and Path(git_path).exists():
|
|
1895
|
+
return git_path
|
|
1896
|
+
except (
|
|
1897
|
+
subprocess.CalledProcessError,
|
|
1898
|
+
FileNotFoundError,
|
|
1899
|
+
subprocess.TimeoutExpired,
|
|
1900
|
+
) as exc:
|
|
1901
|
+
# Non-fatal: failure to use 'which' just means we fall back to other checks.
|
|
1902
|
+
st.write(f"Debug: 'which git' probe failed: {exc}")
|
|
1903
|
+
|
|
1904
|
+
# Last resort: try the basic 'git' command
|
|
1905
|
+
try:
|
|
1906
|
+
result = subprocess.run(
|
|
1907
|
+
["git", "--version"],
|
|
1908
|
+
capture_output=True,
|
|
1909
|
+
text=True,
|
|
1910
|
+
check=True,
|
|
1911
|
+
timeout=5,
|
|
1912
|
+
)
|
|
1913
|
+
if result.returncode == 0:
|
|
1914
|
+
return "git"
|
|
1915
|
+
except (
|
|
1916
|
+
subprocess.CalledProcessError,
|
|
1917
|
+
FileNotFoundError,
|
|
1918
|
+
subprocess.TimeoutExpired,
|
|
1919
|
+
) as exc:
|
|
1920
|
+
# Non-fatal: failure to run 'git --version' just means git is not available.
|
|
1921
|
+
st.write(f"Debug: 'git --version' probe failed: {exc}")
|
|
1922
|
+
|
|
1923
|
+
raise FileNotFoundError(
|
|
1924
|
+
"git executable not found. Please ensure Git is installed and in your "
|
|
1925
|
+
"PATH. Visit https://git-scm.com/downloads for installation instructions."
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def _determine_num_recipes(cookbook_path: str, num_roles: int) -> int:
|
|
1930
|
+
"""Determine the number of recipes from the cookbook path."""
|
|
1931
|
+
if not cookbook_path:
|
|
1932
|
+
return num_roles
|
|
1933
|
+
|
|
1934
|
+
recipes_dir = Path(cookbook_path) / "recipes"
|
|
1935
|
+
return len(list(recipes_dir.glob("*.rb"))) if recipes_dir.exists() else 1
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def _get_roles_directory(temp_repo: Path) -> Path:
|
|
1939
|
+
"""Get or create the roles directory in the repository."""
|
|
1940
|
+
roles_dir = temp_repo / "roles"
|
|
1941
|
+
if not roles_dir.exists():
|
|
1942
|
+
roles_dir = (
|
|
1943
|
+
temp_repo / "ansible_collections" / "souschef" / "platform" / "roles"
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
roles_dir.mkdir(parents=True, exist_ok=True)
|
|
1947
|
+
return roles_dir
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _copy_roles_to_repository(output_path: str, roles_dir: Path) -> None:
|
|
1951
|
+
"""Copy roles from output_path to the repository roles directory."""
|
|
1952
|
+
output_path_obj = Path(output_path)
|
|
1953
|
+
if not output_path_obj.exists():
|
|
1954
|
+
return
|
|
1955
|
+
|
|
1956
|
+
for role_dir in output_path_obj.iterdir():
|
|
1957
|
+
if not role_dir.is_dir():
|
|
1958
|
+
continue
|
|
1959
|
+
|
|
1960
|
+
dest_dir = roles_dir / role_dir.name
|
|
1961
|
+
if dest_dir.exists():
|
|
1962
|
+
shutil.rmtree(dest_dir)
|
|
1963
|
+
shutil.copytree(role_dir, dest_dir)
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def _commit_repository_changes(temp_repo: Path, num_roles: int) -> None:
|
|
1967
|
+
"""Commit repository changes to git."""
|
|
1968
|
+
try:
|
|
1969
|
+
subprocess.run(
|
|
1970
|
+
["git", "add", "."],
|
|
1971
|
+
cwd=temp_repo,
|
|
1972
|
+
check=True,
|
|
1973
|
+
capture_output=True,
|
|
1974
|
+
text=True,
|
|
1975
|
+
)
|
|
1976
|
+
subprocess.run(
|
|
1977
|
+
[
|
|
1978
|
+
"git",
|
|
1979
|
+
"commit",
|
|
1980
|
+
"-m",
|
|
1981
|
+
f"Add converted Ansible roles ({num_roles} role(s))",
|
|
1982
|
+
],
|
|
1983
|
+
cwd=temp_repo,
|
|
1984
|
+
check=True,
|
|
1985
|
+
capture_output=True,
|
|
1986
|
+
text=True,
|
|
1987
|
+
)
|
|
1988
|
+
except subprocess.CalledProcessError:
|
|
1989
|
+
# Ignore if there's nothing to commit
|
|
1990
|
+
pass
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
def _create_ansible_repository(
|
|
1994
|
+
output_path: str, cookbook_path: str = "", num_roles: int = 1
|
|
1995
|
+
) -> dict:
|
|
1996
|
+
"""Create a complete Ansible repository structure."""
|
|
1997
|
+
try:
|
|
1998
|
+
# Check that git is available early
|
|
1999
|
+
_get_git_path()
|
|
2000
|
+
|
|
2001
|
+
# Create temp directory for the repo (parent directory)
|
|
2002
|
+
temp_parent = tempfile.mkdtemp(prefix="ansible_repo_parent_")
|
|
2003
|
+
temp_repo = Path(temp_parent) / "ansible_repository"
|
|
2004
|
+
|
|
2005
|
+
# Analyse and determine repo type
|
|
2006
|
+
num_recipes = _determine_num_recipes(cookbook_path, num_roles)
|
|
2007
|
+
|
|
2008
|
+
repo_type = analyse_conversion_output(
|
|
2009
|
+
cookbook_path=cookbook_path or output_path,
|
|
2010
|
+
num_recipes=num_recipes,
|
|
2011
|
+
num_roles=num_roles,
|
|
2012
|
+
has_multiple_apps=num_roles > 3,
|
|
2013
|
+
needs_multi_env=True,
|
|
2014
|
+
)
|
|
2015
|
+
|
|
2016
|
+
# Generate the repository
|
|
2017
|
+
result = generate_ansible_repository(
|
|
2018
|
+
output_path=str(temp_repo),
|
|
2019
|
+
repo_type=repo_type,
|
|
2020
|
+
org_name="souschef",
|
|
2021
|
+
init_git=True,
|
|
2022
|
+
)
|
|
2023
|
+
|
|
2024
|
+
if result["success"]:
|
|
2025
|
+
# Copy converted roles into the repository
|
|
2026
|
+
roles_dir = _get_roles_directory(temp_repo)
|
|
2027
|
+
_copy_roles_to_repository(output_path, roles_dir)
|
|
2028
|
+
_commit_repository_changes(temp_repo, num_roles)
|
|
2029
|
+
result["temp_path"] = str(temp_repo)
|
|
2030
|
+
|
|
2031
|
+
return result
|
|
2032
|
+
except Exception as e:
|
|
2033
|
+
return {"success": False, "error": str(e)}
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _create_repository_zip(repo_path: str) -> bytes:
|
|
2037
|
+
"""Create a ZIP archive of the Ansible repository including git history."""
|
|
2038
|
+
zip_buffer = io.BytesIO()
|
|
2039
|
+
repo_path_obj = Path(repo_path)
|
|
2040
|
+
|
|
2041
|
+
# Files/directories to exclude from the archive
|
|
2042
|
+
exclude_names = {".DS_Store", "Thumbs.db", "*.pyc", "__pycache__"}
|
|
2043
|
+
|
|
2044
|
+
# Important dotfiles to always include
|
|
2045
|
+
include_dotfiles = {".gitignore", ".gitattributes", ".editorconfig"}
|
|
2046
|
+
|
|
2047
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
2048
|
+
for file_path in repo_path_obj.rglob("*"):
|
|
2049
|
+
if file_path.is_file():
|
|
2050
|
+
# Skip excluded files
|
|
2051
|
+
if file_path.name in exclude_names:
|
|
2052
|
+
continue
|
|
2053
|
+
# Include .git directory, .gitignore, and other important dotfiles
|
|
2054
|
+
# Skip hidden dotfiles unless they're in our include list or in .git
|
|
2055
|
+
if (
|
|
2056
|
+
file_path.name.startswith(".")
|
|
2057
|
+
and ".git" not in str(file_path)
|
|
2058
|
+
and file_path.name not in include_dotfiles
|
|
2059
|
+
):
|
|
2060
|
+
continue
|
|
2061
|
+
|
|
2062
|
+
arcname = file_path.relative_to(repo_path_obj.parent)
|
|
2063
|
+
zip_file.write(str(file_path), str(arcname))
|
|
2064
|
+
|
|
2065
|
+
zip_buffer.seek(0)
|
|
2066
|
+
return zip_buffer.getvalue()
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _display_conversion_download_options(conversion_result: dict):
|
|
2070
|
+
"""Display download options for converted roles."""
|
|
2071
|
+
if "output_path" not in conversion_result:
|
|
2072
|
+
return
|
|
2073
|
+
|
|
2074
|
+
st.subheader("Download Converted Roles")
|
|
2075
|
+
output_path = conversion_result["output_path"]
|
|
2076
|
+
|
|
2077
|
+
safe_output_path = _validate_output_path(output_path)
|
|
2078
|
+
if safe_output_path is None:
|
|
2079
|
+
st.error("Invalid output path")
|
|
2080
|
+
return
|
|
2081
|
+
|
|
2082
|
+
if safe_output_path.exists():
|
|
2083
|
+
_display_role_download_buttons(safe_output_path)
|
|
2084
|
+
repo_placeholder = st.container()
|
|
2085
|
+
_display_generated_repo_section(repo_placeholder)
|
|
2086
|
+
st.info(f"Roles saved to: {output_path}")
|
|
2087
|
+
else:
|
|
2088
|
+
st.warning("Output directory not found for download")
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _create_repo_callback(safe_output_path: Path) -> None:
|
|
2092
|
+
"""Handle repository creation callback."""
|
|
2093
|
+
try:
|
|
2094
|
+
num_roles = len(
|
|
2095
|
+
[
|
|
2096
|
+
d
|
|
2097
|
+
for d in safe_output_path.iterdir()
|
|
2098
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
2099
|
+
]
|
|
2100
|
+
)
|
|
2101
|
+
|
|
2102
|
+
repo_result = _create_ansible_repository(
|
|
2103
|
+
output_path=str(safe_output_path),
|
|
2104
|
+
cookbook_path="",
|
|
2105
|
+
num_roles=num_roles,
|
|
2106
|
+
)
|
|
2107
|
+
|
|
2108
|
+
if repo_result["success"]:
|
|
2109
|
+
st.session_state.generated_repo = repo_result
|
|
2110
|
+
st.session_state.repo_created_successfully = True
|
|
2111
|
+
st.session_state.repo_creation_error = None
|
|
2112
|
+
else:
|
|
2113
|
+
_handle_repo_creation_failure(repo_result.get("error", "Unknown error"))
|
|
2114
|
+
except Exception as e:
|
|
2115
|
+
_handle_repo_creation_failure(f"Exception: {str(e)}")
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _handle_repo_creation_failure(error_msg: str) -> None:
|
|
2119
|
+
"""Handle repository creation failure."""
|
|
2120
|
+
st.session_state.repo_creation_error = error_msg
|
|
2121
|
+
st.session_state.generated_repo = None
|
|
2122
|
+
st.session_state.repo_created_successfully = False
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _display_role_download_buttons(safe_output_path: Path) -> None:
|
|
2126
|
+
"""Display download buttons for roles and repository creation."""
|
|
2127
|
+
col1, col2 = st.columns([1, 1])
|
|
2128
|
+
|
|
2129
|
+
with col1:
|
|
2130
|
+
archive_data = _create_roles_zip_archive(safe_output_path)
|
|
2131
|
+
st.download_button(
|
|
2132
|
+
label="Download All Ansible Roles",
|
|
2133
|
+
data=archive_data,
|
|
2134
|
+
file_name="ansible_roles_holistic.zip",
|
|
2135
|
+
mime=MIME_TYPE_ZIP,
|
|
2136
|
+
help="Download ZIP archive containing all converted Ansible roles",
|
|
2137
|
+
key="download_holistic_roles",
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
with col2:
|
|
2141
|
+
st.button(
|
|
2142
|
+
"Create Ansible Repository",
|
|
2143
|
+
help="Generate a complete Ansible repository structure with these roles",
|
|
2144
|
+
key="create_repo_from_roles",
|
|
2145
|
+
on_click=lambda: _create_repo_callback(safe_output_path),
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
if st.session_state.get("repo_creation_error"):
|
|
2149
|
+
st.error(
|
|
2150
|
+
f"Failed to create repository: {st.session_state.repo_creation_error}"
|
|
2151
|
+
)
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def _display_generated_repo_section(placeholder) -> None:
|
|
2155
|
+
"""Display the generated repository section if it exists."""
|
|
2156
|
+
if not _should_display_generated_repo():
|
|
2157
|
+
return
|
|
2158
|
+
|
|
2159
|
+
repo_result = st.session_state.generated_repo
|
|
2160
|
+
|
|
2161
|
+
with placeholder:
|
|
2162
|
+
st.markdown("---")
|
|
2163
|
+
st.success("Ansible Repository Generated!")
|
|
2164
|
+
_display_repo_info(repo_result)
|
|
2165
|
+
_display_repo_structure(repo_result)
|
|
2166
|
+
_display_repo_download(repo_result)
|
|
2167
|
+
_display_repo_git_instructions()
|
|
2168
|
+
_display_repo_clear_button(repo_result)
|
|
2169
|
+
|
|
2170
|
+
|
|
2171
|
+
def _should_display_generated_repo() -> bool:
|
|
2172
|
+
"""Check if generated repo should be displayed."""
|
|
2173
|
+
return "generated_repo" in st.session_state and st.session_state.get(
|
|
2174
|
+
"repo_created_successfully", False
|
|
2175
|
+
)
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _display_repo_info(repo_result: dict) -> None:
|
|
2179
|
+
"""Display repository information."""
|
|
2180
|
+
repo_type = repo_result["repo_type"].replace("_", " ").title()
|
|
2181
|
+
files_count = len(repo_result["files_created"])
|
|
2182
|
+
|
|
2183
|
+
st.info(
|
|
2184
|
+
f"**Repository Type:** {repo_type}\n\n"
|
|
2185
|
+
f"**Files Created:** {files_count}\n\n"
|
|
2186
|
+
"Includes: ansible.cfg, requirements.yml, inventory, playbooks, roles"
|
|
2187
|
+
)
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
def _display_repo_structure(repo_result: dict) -> None:
|
|
2191
|
+
"""Display repository structure."""
|
|
2192
|
+
with st.expander("Repository Structure", expanded=True):
|
|
2193
|
+
files_sorted = sorted(repo_result["files_created"])
|
|
2194
|
+
st.code("\n".join(files_sorted[:40]), language="text")
|
|
2195
|
+
if len(files_sorted) > 40:
|
|
2196
|
+
remaining = len(files_sorted) - 40
|
|
2197
|
+
st.caption(f"... and {remaining} more files")
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _display_repo_download(repo_result: dict) -> None:
|
|
2201
|
+
"""Display repository download button."""
|
|
2202
|
+
repo_zip = _create_repository_zip(repo_result["temp_path"])
|
|
2203
|
+
st.download_button(
|
|
2204
|
+
label="Download Ansible Repository",
|
|
2205
|
+
data=repo_zip,
|
|
2206
|
+
file_name="ansible_repository.zip",
|
|
2207
|
+
mime=MIME_TYPE_ZIP,
|
|
2208
|
+
help="Download complete Ansible repository as ZIP archive",
|
|
2209
|
+
key="download_generated_repo",
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def _display_repo_git_instructions() -> None:
|
|
2214
|
+
"""Display git clone instructions."""
|
|
2215
|
+
with st.expander("Git Clone Instructions", expanded=True):
|
|
2216
|
+
st.markdown("""
|
|
2217
|
+
After downloading and extracting the repository:
|
|
2218
|
+
|
|
2219
|
+
```bash
|
|
2220
|
+
cd ansible_repository
|
|
2221
|
+
|
|
2222
|
+
# Repository is already initialized with git!
|
|
2223
|
+
# Check commits:
|
|
2224
|
+
git log --oneline
|
|
2225
|
+
|
|
2226
|
+
# Push to remote repository:
|
|
2227
|
+
git remote add origin <your-git-url>
|
|
2228
|
+
git push -u origin master
|
|
2229
|
+
```
|
|
2230
|
+
|
|
2231
|
+
**Repository includes:**
|
|
2232
|
+
- ✅ All converted roles with tasks
|
|
2233
|
+
- ✅ Ansible configuration (`ansible.cfg`)
|
|
2234
|
+
- ✅ `.gitignore` for Ansible projects
|
|
2235
|
+
- ✅ `.gitattributes` for consistent line endings
|
|
2236
|
+
- ✅ `.editorconfig` for consistent coding styles
|
|
2237
|
+
- ✅ README with usage instructions
|
|
2238
|
+
- ✅ **Git repository initialized with all files committed**
|
|
2239
|
+
""")
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
def _display_repo_clear_button(repo_result: dict) -> None:
|
|
2243
|
+
"""Display repository clear button."""
|
|
2244
|
+
if st.button("Clear Repository", key="clear_generated_repo"):
|
|
2245
|
+
with contextlib.suppress(Exception):
|
|
2246
|
+
shutil.rmtree(repo_result["temp_path"])
|
|
2247
|
+
del st.session_state.generated_repo
|
|
2248
|
+
if "repo_created_successfully" in st.session_state:
|
|
2249
|
+
del st.session_state.repo_created_successfully
|
|
2250
|
+
st.rerun()
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
def _handle_dashboard_upload():
|
|
2254
|
+
"""Handle file uploaded from the dashboard."""
|
|
2255
|
+
# Create a file-like object from the stored data
|
|
2256
|
+
file_data = st.session_state.uploaded_file_data
|
|
2257
|
+
file_name = st.session_state.uploaded_file_name
|
|
2258
|
+
|
|
2259
|
+
# Create a file-like object that mimics the UploadedFile interface
|
|
2260
|
+
class MockUploadedFile:
|
|
2261
|
+
def __init__(self, data, name, mime_type):
|
|
2262
|
+
self.data = data
|
|
2263
|
+
self.name = name
|
|
2264
|
+
self.type = mime_type
|
|
2265
|
+
|
|
2266
|
+
def getbuffer(self):
|
|
2267
|
+
return self.data
|
|
2268
|
+
|
|
2269
|
+
def getvalue(self):
|
|
2270
|
+
return self.data
|
|
2271
|
+
|
|
2272
|
+
mock_file = MockUploadedFile(
|
|
2273
|
+
file_data, file_name, st.session_state.uploaded_file_type
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
# Display upload info
|
|
2277
|
+
st.info(f"Using file uploaded from Dashboard: {file_name}")
|
|
2278
|
+
|
|
2279
|
+
# Add option to clear and upload a different file
|
|
2280
|
+
col1, col2 = st.columns([1, 1])
|
|
2281
|
+
with col1:
|
|
2282
|
+
if st.button(
|
|
2283
|
+
"Use Different File",
|
|
2284
|
+
help="Clear this file and upload a different one",
|
|
2285
|
+
key="use_different_file",
|
|
2286
|
+
):
|
|
2287
|
+
# Clear the uploaded file from session state
|
|
2288
|
+
del st.session_state.uploaded_file_data
|
|
2289
|
+
del st.session_state.uploaded_file_name
|
|
2290
|
+
del st.session_state.uploaded_file_type
|
|
2291
|
+
st.rerun()
|
|
2292
|
+
|
|
2293
|
+
with col2:
|
|
2294
|
+
if st.button(
|
|
2295
|
+
"Back to Dashboard", help="Return to dashboard", key="back_to_dashboard"
|
|
2296
|
+
):
|
|
2297
|
+
st.session_state.current_page = "Dashboard"
|
|
2298
|
+
st.rerun()
|
|
2299
|
+
|
|
2300
|
+
# Process the file
|
|
2301
|
+
try:
|
|
2302
|
+
with st.spinner("Extracting archive..."):
|
|
2303
|
+
temp_dir, cookbook_path = extract_archive(mock_file)
|
|
2304
|
+
# Store temp_dir in session state to prevent premature cleanup
|
|
2305
|
+
st.session_state.temp_dir = temp_dir
|
|
2306
|
+
st.success("Archive extracted successfully!")
|
|
2307
|
+
|
|
2308
|
+
# Validate and list cookbooks
|
|
2309
|
+
if cookbook_path:
|
|
2310
|
+
_validate_and_list_cookbooks(str(cookbook_path))
|
|
2311
|
+
|
|
2312
|
+
except Exception as e:
|
|
2313
|
+
st.error(f"Failed to process uploaded file: {e}")
|
|
2314
|
+
# Clear the uploaded file on error
|
|
2315
|
+
if "uploaded_file_data" in st.session_state:
|
|
2316
|
+
del st.session_state.uploaded_file_data
|
|
2317
|
+
del st.session_state.uploaded_file_name
|
|
2318
|
+
del st.session_state.uploaded_file_type
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
def _display_instructions():
|
|
2322
|
+
"""Display usage instructions."""
|
|
2323
|
+
with st.expander("How to Use"):
|
|
2324
|
+
st.markdown("""
|
|
2325
|
+
## Input Methods
|
|
2326
|
+
|
|
2327
|
+
### Directory Path
|
|
2328
|
+
1. **Enter Cookbook Path**: Provide a **relative path** to your cookbooks
|
|
2329
|
+
(absolute paths not allowed)
|
|
2330
|
+
2. **Review Cookbooks**: The interface will list all cookbooks with metadata
|
|
2331
|
+
3. **Select Cookbooks**: Choose which cookbooks to analyse
|
|
2332
|
+
4. **Run Analysis**: Click "Analyse Selected Cookbooks" to get detailed insights
|
|
2333
|
+
|
|
2334
|
+
**Path Examples:**
|
|
2335
|
+
- `cookbooks/` - subdirectory in current workspace
|
|
2336
|
+
- `../shared/cookbooks/` - parent directory
|
|
2337
|
+
- `./my-cookbooks/` - explicit current directory
|
|
2338
|
+
|
|
2339
|
+
### Archive Upload
|
|
2340
|
+
1. **Upload Archive**: Upload a ZIP or TAR archive containing your cookbooks
|
|
2341
|
+
2. **Automatic Extraction**: The system will extract and analyse the archive
|
|
2342
|
+
|
|
2343
|
+
3. **Review Cookbooks**: Interface will list all cookbooks found in archive
|
|
2344
|
+
4. **Select Cookbooks**: Choose which cookbooks to analyse
|
|
2345
|
+
5. **Run Analysis**: Click "Analyse Selected Cookbooks" to get insights
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
## Expected Structure
|
|
2349
|
+
```
|
|
2350
|
+
cookbooks/ or archive.zip/
|
|
2351
|
+
├── nginx/
|
|
2352
|
+
│ ├── metadata.rb
|
|
2353
|
+
│ ├── recipes/
|
|
2354
|
+
│ └── attributes/
|
|
2355
|
+
├── apache2/
|
|
2356
|
+
│ └── metadata.rb
|
|
2357
|
+
└── mysql/
|
|
2358
|
+
└── metadata.rb
|
|
2359
|
+
```
|
|
2360
|
+
|
|
2361
|
+
## Supported Archive Formats
|
|
2362
|
+
- ZIP (.zip)
|
|
2363
|
+
- TAR (.tar)
|
|
2364
|
+
- GZIP-compressed TAR (.tar.gz, .tgz)
|
|
2365
|
+
""")
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def analyse_selected_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
|
|
2369
|
+
"""Analyse the selected cookbooks and store results in session state."""
|
|
2370
|
+
st.subheader("Analysis Results")
|
|
2371
|
+
|
|
2372
|
+
progress_bar, status_text = _setup_analysis_progress()
|
|
2373
|
+
results = _perform_cookbook_analysis(
|
|
907
2374
|
cookbook_path, selected_cookbooks, progress_bar, status_text
|
|
908
2375
|
)
|
|
909
2376
|
|
|
@@ -944,95 +2411,936 @@ def _perform_cookbook_analysis(
|
|
|
944
2411
|
return results
|
|
945
2412
|
|
|
946
2413
|
|
|
947
|
-
def _update_progress(status_text, cookbook_name, current, total):
|
|
948
|
-
"""Update progress display."""
|
|
949
|
-
|
|
2414
|
+
def _update_progress(status_text, cookbook_name, current, total):
|
|
2415
|
+
"""Update progress display."""
|
|
2416
|
+
# Check if AI is configured
|
|
2417
|
+
ai_config = load_ai_settings()
|
|
2418
|
+
ai_available = (
|
|
2419
|
+
ai_config.get("provider")
|
|
2420
|
+
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
2421
|
+
and ai_config.get("api_key")
|
|
2422
|
+
)
|
|
2423
|
+
|
|
2424
|
+
ai_indicator = " [AI-ENHANCED]" if ai_available else " [RULE-BASED]"
|
|
2425
|
+
status_text.text(f"Analyzing {cookbook_name}{ai_indicator}... ({current}/{total})")
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _find_cookbook_directory(cookbook_path, cookbook_name):
|
|
2429
|
+
"""Find the directory for a specific cookbook by checking metadata."""
|
|
2430
|
+
try:
|
|
2431
|
+
normalized_path = _normalize_path(cookbook_path)
|
|
2432
|
+
for d in normalized_path.iterdir():
|
|
2433
|
+
if d.is_dir():
|
|
2434
|
+
# Check if this directory contains a cookbook with the matching name
|
|
2435
|
+
metadata_file = d / METADATA_FILENAME
|
|
2436
|
+
if metadata_file.exists():
|
|
2437
|
+
try:
|
|
2438
|
+
metadata = parse_cookbook_metadata(str(metadata_file))
|
|
2439
|
+
if metadata.get("name") == cookbook_name:
|
|
2440
|
+
return d
|
|
2441
|
+
except (ValueError, OSError, KeyError):
|
|
2442
|
+
# If metadata parsing fails, skip this directory
|
|
2443
|
+
continue
|
|
2444
|
+
except ValueError:
|
|
2445
|
+
# Invalid path, return None
|
|
2446
|
+
return None
|
|
2447
|
+
return None
|
|
2448
|
+
|
|
2449
|
+
|
|
2450
|
+
def _analyse_single_cookbook(cookbook_name, cookbook_dir):
|
|
2451
|
+
"""Analyse a single cookbook."""
|
|
2452
|
+
try:
|
|
2453
|
+
metadata = _load_cookbook_metadata(cookbook_name, cookbook_dir)
|
|
2454
|
+
if "error" in metadata:
|
|
2455
|
+
return metadata # Return error result
|
|
2456
|
+
|
|
2457
|
+
ai_config = load_ai_settings()
|
|
2458
|
+
use_ai = _should_use_ai(ai_config)
|
|
2459
|
+
|
|
2460
|
+
if use_ai:
|
|
2461
|
+
assessment = _run_ai_analysis(cookbook_dir, ai_config)
|
|
2462
|
+
else:
|
|
2463
|
+
assessment = _run_rule_based_analysis(cookbook_dir)
|
|
2464
|
+
|
|
2465
|
+
if isinstance(assessment, dict) and "error" in assessment:
|
|
2466
|
+
return _create_failed_analysis(
|
|
2467
|
+
cookbook_name, cookbook_dir, assessment["error"]
|
|
2468
|
+
)
|
|
2469
|
+
|
|
2470
|
+
return _create_successful_analysis(
|
|
2471
|
+
cookbook_name, cookbook_dir, assessment, metadata
|
|
2472
|
+
)
|
|
2473
|
+
except Exception as e:
|
|
2474
|
+
import traceback
|
|
2475
|
+
|
|
2476
|
+
error_details = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
2477
|
+
return _create_failed_analysis(cookbook_name, cookbook_dir, error_details)
|
|
2478
|
+
|
|
2479
|
+
|
|
2480
|
+
def _load_cookbook_metadata(cookbook_name: str, cookbook_dir: Path) -> dict[str, Any]:
|
|
2481
|
+
"""
|
|
2482
|
+
Load and parse cookbook metadata.
|
|
2483
|
+
|
|
2484
|
+
Args:
|
|
2485
|
+
cookbook_name: Name of the cookbook.
|
|
2486
|
+
cookbook_dir: Directory containing the cookbook.
|
|
2487
|
+
|
|
2488
|
+
Returns:
|
|
2489
|
+
Metadata dictionary or error result.
|
|
2490
|
+
|
|
2491
|
+
"""
|
|
2492
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
2493
|
+
if not metadata_file.exists():
|
|
2494
|
+
return _create_failed_analysis( # type: ignore[no-any-return]
|
|
2495
|
+
cookbook_name,
|
|
2496
|
+
cookbook_dir,
|
|
2497
|
+
f"No {METADATA_FILENAME} found in {cookbook_dir}",
|
|
2498
|
+
)
|
|
2499
|
+
|
|
2500
|
+
try:
|
|
2501
|
+
return parse_cookbook_metadata(str(metadata_file))
|
|
2502
|
+
except Exception as e:
|
|
2503
|
+
return _create_failed_analysis( # type: ignore[no-any-return]
|
|
2504
|
+
cookbook_name, cookbook_dir, f"Failed to parse metadata: {e}"
|
|
2505
|
+
)
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
def _should_use_ai(ai_config: dict) -> bool:
|
|
2509
|
+
"""
|
|
2510
|
+
Check if AI-enhanced analysis should be used.
|
|
2511
|
+
|
|
2512
|
+
Args:
|
|
2513
|
+
ai_config: AI configuration dictionary.
|
|
2514
|
+
|
|
2515
|
+
Returns:
|
|
2516
|
+
True if AI analysis should be used.
|
|
2517
|
+
|
|
2518
|
+
"""
|
|
2519
|
+
return bool(
|
|
2520
|
+
ai_config.get("provider")
|
|
2521
|
+
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
2522
|
+
and ai_config.get("api_key")
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
def _run_ai_analysis(cookbook_dir: Path, ai_config: dict) -> dict:
|
|
2527
|
+
"""
|
|
2528
|
+
Run AI-enhanced cookbook analysis.
|
|
2529
|
+
|
|
2530
|
+
Args:
|
|
2531
|
+
cookbook_dir: Directory containing the cookbook.
|
|
2532
|
+
ai_config: AI configuration dictionary.
|
|
2533
|
+
|
|
2534
|
+
Returns:
|
|
2535
|
+
Assessment dictionary.
|
|
2536
|
+
|
|
2537
|
+
"""
|
|
2538
|
+
ai_provider = _determine_ai_provider(ai_config)
|
|
2539
|
+
|
|
2540
|
+
return assess_single_cookbook_with_ai(
|
|
2541
|
+
str(cookbook_dir),
|
|
2542
|
+
ai_provider=ai_provider or "anthropic",
|
|
2543
|
+
api_key=str(ai_config.get("api_key", "")),
|
|
2544
|
+
model=str(ai_config.get("model", "claude-3-5-sonnet-20241022")),
|
|
2545
|
+
temperature=float(ai_config.get("temperature", 0.7)),
|
|
2546
|
+
max_tokens=int(ai_config.get("max_tokens", 4000)),
|
|
2547
|
+
project_id=str(ai_config.get("project_id", "")),
|
|
2548
|
+
base_url=str(ai_config.get("base_url", "")),
|
|
2549
|
+
)
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def _determine_ai_provider(ai_config: dict) -> str:
|
|
2553
|
+
"""
|
|
2554
|
+
Determine AI provider name from config.
|
|
2555
|
+
|
|
2556
|
+
Args:
|
|
2557
|
+
ai_config: AI configuration dictionary.
|
|
2558
|
+
|
|
2559
|
+
Returns:
|
|
2560
|
+
Provider string.
|
|
2561
|
+
|
|
2562
|
+
"""
|
|
2563
|
+
provider_mapping = {
|
|
2564
|
+
ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
|
|
2565
|
+
ANTHROPIC_PROVIDER: "anthropic",
|
|
2566
|
+
"OpenAI": "openai",
|
|
2567
|
+
OPENAI_PROVIDER: "openai",
|
|
2568
|
+
IBM_WATSONX: "watson",
|
|
2569
|
+
RED_HAT_LIGHTSPEED: "lightspeed",
|
|
2570
|
+
}
|
|
2571
|
+
provider_name_raw = ai_config.get("provider", "")
|
|
2572
|
+
provider_name = str(provider_name_raw) if provider_name_raw else ""
|
|
2573
|
+
return provider_mapping.get(
|
|
2574
|
+
provider_name,
|
|
2575
|
+
provider_name.lower().replace(" ", "_") if provider_name else "anthropic",
|
|
2576
|
+
)
|
|
2577
|
+
|
|
2578
|
+
|
|
2579
|
+
def _run_rule_based_analysis(cookbook_dir: Path) -> dict:
|
|
2580
|
+
"""
|
|
2581
|
+
Run rule-based cookbook analysis.
|
|
2582
|
+
|
|
2583
|
+
Args:
|
|
2584
|
+
cookbook_dir: Directory containing the cookbook.
|
|
2585
|
+
|
|
2586
|
+
Returns:
|
|
2587
|
+
Assessment dictionary.
|
|
2588
|
+
|
|
2589
|
+
"""
|
|
2590
|
+
from souschef.assessment import parse_chef_migration_assessment
|
|
2591
|
+
|
|
2592
|
+
assessment = parse_chef_migration_assessment(str(cookbook_dir))
|
|
2593
|
+
|
|
2594
|
+
# Extract single cookbook assessment if multi-cookbook structure returned
|
|
2595
|
+
if "cookbook_assessments" in assessment and assessment["cookbook_assessments"]:
|
|
2596
|
+
cookbook_assessment = assessment["cookbook_assessments"][0]
|
|
2597
|
+
return {
|
|
2598
|
+
"complexity": assessment.get("complexity", "Unknown"),
|
|
2599
|
+
"estimated_hours": assessment.get("estimated_hours", 0),
|
|
2600
|
+
"recommendations": _format_recommendations_from_assessment(
|
|
2601
|
+
cookbook_assessment, assessment
|
|
2602
|
+
),
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
return assessment
|
|
2606
|
+
|
|
2607
|
+
|
|
2608
|
+
def _create_successful_analysis(
|
|
2609
|
+
cookbook_name: str, cookbook_dir: Path, assessment: dict, metadata: dict
|
|
2610
|
+
) -> dict:
|
|
2611
|
+
"""Create analysis result for successful analysis."""
|
|
2612
|
+
return {
|
|
2613
|
+
"name": cookbook_name,
|
|
2614
|
+
"path": str(cookbook_dir),
|
|
2615
|
+
"version": metadata.get("version", "Unknown"),
|
|
2616
|
+
"maintainer": metadata.get("maintainer", "Unknown"),
|
|
2617
|
+
"description": metadata.get("description", "No description"),
|
|
2618
|
+
"dependencies": len(metadata.get("depends", [])),
|
|
2619
|
+
"complexity": assessment.get("complexity", "Unknown"),
|
|
2620
|
+
"estimated_hours": assessment.get("estimated_hours", 0),
|
|
2621
|
+
"recommendations": assessment.get("recommendations", ""),
|
|
2622
|
+
"status": ANALYSIS_STATUS_ANALYSED,
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
def _format_recommendations_from_assessment(
|
|
2627
|
+
cookbook_assessment: dict, overall_assessment: dict
|
|
2628
|
+
) -> str:
|
|
2629
|
+
"""Format recommendations from the detailed assessment structure."""
|
|
2630
|
+
recommendations: list[str] = []
|
|
2631
|
+
|
|
2632
|
+
# Add cookbook-specific details
|
|
2633
|
+
_add_complexity_score(recommendations, cookbook_assessment)
|
|
2634
|
+
_add_effort_estimate(recommendations, cookbook_assessment)
|
|
2635
|
+
_add_migration_priority(recommendations, cookbook_assessment)
|
|
2636
|
+
_add_key_findings(recommendations, cookbook_assessment)
|
|
2637
|
+
_add_overall_recommendations(recommendations, overall_assessment)
|
|
2638
|
+
|
|
2639
|
+
return "\n".join(recommendations) if recommendations else "Analysis completed"
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def _add_complexity_score(recommendations: list[str], assessment: dict) -> None:
|
|
2643
|
+
"""Add complexity score to recommendations."""
|
|
2644
|
+
if "complexity_score" in assessment:
|
|
2645
|
+
recommendations.append(
|
|
2646
|
+
f"Complexity Score: {assessment['complexity_score']}/100"
|
|
2647
|
+
)
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
def _add_effort_estimate(recommendations: list[str], assessment: dict) -> None:
|
|
2651
|
+
"""Add effort estimate to recommendations."""
|
|
2652
|
+
if "estimated_effort_days" not in assessment:
|
|
2653
|
+
return
|
|
2654
|
+
|
|
2655
|
+
estimated_days = assessment["estimated_effort_days"]
|
|
2656
|
+
effort_metrics = EffortMetrics(estimated_days)
|
|
2657
|
+
complexity = assessment.get("complexity", "Medium")
|
|
2658
|
+
is_valid, _ = validate_metrics_consistency(
|
|
2659
|
+
days=effort_metrics.estimated_days,
|
|
2660
|
+
weeks=effort_metrics.estimated_weeks_range,
|
|
2661
|
+
hours=effort_metrics.estimated_hours,
|
|
2662
|
+
complexity=complexity,
|
|
2663
|
+
)
|
|
2664
|
+
if is_valid:
|
|
2665
|
+
recommendations.append(
|
|
2666
|
+
f"Estimated Effort: {effort_metrics.estimated_days_formatted}"
|
|
2667
|
+
)
|
|
2668
|
+
else:
|
|
2669
|
+
recommendations.append(f"Estimated Effort: {estimated_days} days")
|
|
2670
|
+
|
|
2671
|
+
|
|
2672
|
+
def _add_migration_priority(recommendations: list[str], assessment: dict) -> None:
|
|
2673
|
+
"""Add migration priority to recommendations."""
|
|
2674
|
+
if "migration_priority" in assessment:
|
|
2675
|
+
recommendations.append(
|
|
2676
|
+
f"Migration Priority: {assessment['migration_priority']}"
|
|
2677
|
+
)
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
def _add_key_findings(recommendations: list[str], assessment: dict) -> None:
|
|
2681
|
+
"""Add key findings to recommendations."""
|
|
2682
|
+
if not assessment.get("key_findings"):
|
|
2683
|
+
return
|
|
2684
|
+
|
|
2685
|
+
recommendations.append("\nKey Findings:")
|
|
2686
|
+
for finding in assessment["key_findings"]:
|
|
2687
|
+
recommendations.append(f" - {finding}")
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
def _add_overall_recommendations(
|
|
2691
|
+
recommendations: list[str], overall_assessment: dict
|
|
2692
|
+
) -> None:
|
|
2693
|
+
"""Add overall recommendations to recommendations."""
|
|
2694
|
+
rec_data = overall_assessment.get("recommendations")
|
|
2695
|
+
if not rec_data:
|
|
2696
|
+
return
|
|
2697
|
+
|
|
2698
|
+
recommendations.append("\nRecommendations:")
|
|
2699
|
+
if isinstance(rec_data, list):
|
|
2700
|
+
for rec in rec_data:
|
|
2701
|
+
if isinstance(rec, dict) and "recommendation" in rec:
|
|
2702
|
+
recommendations.append(f" - {rec['recommendation']}")
|
|
2703
|
+
elif isinstance(rec, str):
|
|
2704
|
+
recommendations.append(f" - {rec}")
|
|
2705
|
+
elif isinstance(rec_data, str):
|
|
2706
|
+
recommendations.append(f" - {rec_data}")
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def _get_error_context(cookbook_dir: Path) -> str:
|
|
2710
|
+
"""Get context information about why analysis might have failed."""
|
|
2711
|
+
context_parts = []
|
|
2712
|
+
|
|
2713
|
+
# Check basic structure
|
|
2714
|
+
validation = _validate_cookbook_structure(cookbook_dir)
|
|
2715
|
+
|
|
2716
|
+
missing_items = [check for check, valid in validation.items() if not valid]
|
|
2717
|
+
if missing_items:
|
|
2718
|
+
context_parts.append(f"Missing: {', '.join(missing_items)}")
|
|
2719
|
+
|
|
2720
|
+
# Check if metadata parsing failed
|
|
2721
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
2722
|
+
if metadata_file.exists():
|
|
2723
|
+
try:
|
|
2724
|
+
parse_cookbook_metadata(str(metadata_file))
|
|
2725
|
+
context_parts.append("metadata.rb exists and parses successfully")
|
|
2726
|
+
except Exception as e:
|
|
2727
|
+
context_parts.append(f"metadata.rb parsing error: {str(e)[:100]}")
|
|
2728
|
+
|
|
2729
|
+
# Check AI configuration if using AI
|
|
2730
|
+
ai_config = load_ai_settings()
|
|
2731
|
+
use_ai = (
|
|
2732
|
+
ai_config.get("provider")
|
|
2733
|
+
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
2734
|
+
and ai_config.get("api_key")
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
if use_ai:
|
|
2738
|
+
context_parts.append(
|
|
2739
|
+
f"Using AI analysis with {ai_config.get('provider', 'Unknown')}"
|
|
2740
|
+
)
|
|
2741
|
+
if not ai_config.get("api_key"):
|
|
2742
|
+
context_parts.append("AI configured but no API key provided")
|
|
2743
|
+
else:
|
|
2744
|
+
context_parts.append("Using rule-based analysis (AI not configured)")
|
|
2745
|
+
|
|
2746
|
+
return (
|
|
2747
|
+
"; ".join(context_parts) if context_parts else "No additional context available"
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
|
|
2751
|
+
def _create_failed_analysis(cookbook_name, cookbook_dir, error_message):
|
|
2752
|
+
"""Create analysis result for failed analysis."""
|
|
2753
|
+
# Add context to the error message
|
|
2754
|
+
context_info = _get_error_context(cookbook_dir)
|
|
2755
|
+
full_error = f"{error_message}\n\nContext: {context_info}"
|
|
2756
|
+
|
|
2757
|
+
return {
|
|
2758
|
+
"name": cookbook_name,
|
|
2759
|
+
"path": str(cookbook_dir),
|
|
2760
|
+
"version": "Error",
|
|
2761
|
+
"maintainer": "Error",
|
|
2762
|
+
"description": (
|
|
2763
|
+
f"Analysis failed: {error_message[:100]}"
|
|
2764
|
+
f"{'...' if len(error_message) > 100 else ''}"
|
|
2765
|
+
),
|
|
2766
|
+
"dependencies": 0,
|
|
2767
|
+
"complexity": "Error",
|
|
2768
|
+
"estimated_hours": 0,
|
|
2769
|
+
"recommendations": full_error,
|
|
2770
|
+
"status": ANALYSIS_STATUS_FAILED,
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
def _cleanup_progress_indicators(progress_bar, status_text):
|
|
2775
|
+
"""Clean up progress indicators."""
|
|
2776
|
+
progress_bar.empty()
|
|
2777
|
+
status_text.empty()
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
def analyse_project_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
|
|
2781
|
+
"""Analyse cookbooks as a project with dependency analysis."""
|
|
2782
|
+
st.subheader("Project-Level Analysis Results")
|
|
2783
|
+
|
|
2784
|
+
progress_bar, status_text = _setup_analysis_progress()
|
|
2785
|
+
results = _perform_cookbook_analysis(
|
|
2786
|
+
cookbook_path, selected_cookbooks, progress_bar, status_text
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
# Perform project-level dependency analysis
|
|
2790
|
+
status_text.text("Analyzing project dependencies...")
|
|
2791
|
+
project_analysis = _analyse_project_dependencies(
|
|
2792
|
+
cookbook_path, selected_cookbooks, results
|
|
2793
|
+
)
|
|
2794
|
+
|
|
2795
|
+
_cleanup_progress_indicators(progress_bar, status_text)
|
|
2796
|
+
|
|
2797
|
+
# Store results in session state
|
|
2798
|
+
st.session_state.analysis_results = results
|
|
2799
|
+
st.session_state.analysis_cookbook_path = cookbook_path
|
|
2800
|
+
st.session_state.total_cookbooks = len(selected_cookbooks)
|
|
2801
|
+
st.session_state.project_analysis = project_analysis
|
|
2802
|
+
|
|
2803
|
+
# Trigger rerun to display results
|
|
2804
|
+
st.rerun()
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def _analyse_project_dependencies(
|
|
2808
|
+
cookbook_path: str, selected_cookbooks: list[str], individual_results: list
|
|
2809
|
+
) -> dict:
|
|
2810
|
+
"""Analyze dependencies across all cookbooks in the project."""
|
|
2811
|
+
project_analysis = {
|
|
2812
|
+
"dependency_graph": {},
|
|
2813
|
+
"migration_order": [],
|
|
2814
|
+
"circular_dependencies": [],
|
|
2815
|
+
"project_complexity": "Low",
|
|
2816
|
+
"project_effort_days": 0,
|
|
2817
|
+
"migration_strategy": "phased",
|
|
2818
|
+
"risks": [],
|
|
2819
|
+
"recommendations": [],
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
try:
|
|
2823
|
+
# Build dependency graph
|
|
2824
|
+
dependency_graph = _build_dependency_graph(cookbook_path, selected_cookbooks)
|
|
2825
|
+
project_analysis["dependency_graph"] = dependency_graph
|
|
2826
|
+
|
|
2827
|
+
# Determine migration order using topological sort
|
|
2828
|
+
migration_order = _calculate_migration_order(
|
|
2829
|
+
dependency_graph, individual_results
|
|
2830
|
+
)
|
|
2831
|
+
project_analysis["migration_order"] = migration_order
|
|
2832
|
+
|
|
2833
|
+
# Identify circular dependencies
|
|
2834
|
+
circular_deps = _find_circular_dependencies(dependency_graph)
|
|
2835
|
+
project_analysis["circular_dependencies"] = circular_deps
|
|
2836
|
+
|
|
2837
|
+
# Calculate project-level metrics
|
|
2838
|
+
project_metrics = _calculate_project_metrics(
|
|
2839
|
+
individual_results, dependency_graph
|
|
2840
|
+
)
|
|
2841
|
+
project_analysis.update(project_metrics)
|
|
2842
|
+
|
|
2843
|
+
# Generate project recommendations
|
|
2844
|
+
recommendations = _generate_project_recommendations(
|
|
2845
|
+
project_analysis, individual_results
|
|
2846
|
+
)
|
|
2847
|
+
project_analysis["recommendations"] = recommendations
|
|
2848
|
+
|
|
2849
|
+
except Exception as e:
|
|
2850
|
+
st.warning(f"Project dependency analysis failed: {e}")
|
|
2851
|
+
# Continue with basic analysis
|
|
2852
|
+
|
|
2853
|
+
return project_analysis
|
|
2854
|
+
|
|
2855
|
+
|
|
2856
|
+
def _build_dependency_graph(cookbook_path: str, selected_cookbooks: list[str]) -> dict:
|
|
2857
|
+
"""Build a dependency graph for all cookbooks in the project."""
|
|
2858
|
+
dependency_graph = {}
|
|
2859
|
+
|
|
2860
|
+
for cookbook_name in selected_cookbooks:
|
|
2861
|
+
cookbook_dir = _find_cookbook_directory(cookbook_path, cookbook_name)
|
|
2862
|
+
if cookbook_dir:
|
|
2863
|
+
try:
|
|
2864
|
+
# Use the existing dependency analysis function
|
|
2865
|
+
dep_analysis = analyse_cookbook_dependencies(str(cookbook_dir))
|
|
2866
|
+
# Parse the markdown response to extract dependencies
|
|
2867
|
+
dependencies = _extract_dependencies_from_markdown(dep_analysis)
|
|
2868
|
+
dependency_graph[cookbook_name] = dependencies
|
|
2869
|
+
except (ValueError, OSError, RuntimeError):
|
|
2870
|
+
# If dependency analysis fails, assume no dependencies
|
|
2871
|
+
dependency_graph[cookbook_name] = []
|
|
2872
|
+
|
|
2873
|
+
return dependency_graph
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
def _extract_dependencies_from_markdown(markdown_text: str) -> list[str]:
|
|
2877
|
+
"""Extract dependencies from markdown output of analyse_cookbook_dependencies."""
|
|
2878
|
+
dependencies = []
|
|
2879
|
+
|
|
2880
|
+
# Look for the dependency graph section
|
|
2881
|
+
lines = markdown_text.split("\n")
|
|
2882
|
+
in_graph_section = False
|
|
2883
|
+
|
|
2884
|
+
for line in lines:
|
|
2885
|
+
if "## Dependency Graph:" in line:
|
|
2886
|
+
in_graph_section = True
|
|
2887
|
+
elif in_graph_section and line.startswith("##"):
|
|
2888
|
+
break
|
|
2889
|
+
elif in_graph_section and "├──" in line:
|
|
2890
|
+
# Extract dependency name
|
|
2891
|
+
dep_line = line.strip()
|
|
2892
|
+
if "├──" in dep_line:
|
|
2893
|
+
dep_name = dep_line.split("├──")[-1].strip()
|
|
2894
|
+
if dep_name and dep_name != "External dependencies:":
|
|
2895
|
+
dependencies.append(dep_name)
|
|
2896
|
+
|
|
2897
|
+
return dependencies
|
|
2898
|
+
|
|
2899
|
+
|
|
2900
|
+
def _calculate_migration_order(
|
|
2901
|
+
dependency_graph: dict, individual_results: list
|
|
2902
|
+
) -> list[dict]:
|
|
2903
|
+
"""Calculate optimal migration order using topological sort."""
|
|
2904
|
+
order = _perform_topological_sort(dependency_graph)
|
|
2905
|
+
|
|
2906
|
+
# If topological sort failed due to cycles, fall back to complexity-based ordering
|
|
2907
|
+
if len(order) != len(dependency_graph):
|
|
2908
|
+
order = _fallback_migration_order(individual_results)
|
|
2909
|
+
|
|
2910
|
+
# Convert to detailed order with metadata
|
|
2911
|
+
return _build_detailed_migration_order(order, dependency_graph, individual_results)
|
|
2912
|
+
|
|
2913
|
+
|
|
2914
|
+
def _perform_topological_sort(dependency_graph: dict) -> list[str]:
|
|
2915
|
+
"""Perform topological sort on dependency graph."""
|
|
2916
|
+
visited = set()
|
|
2917
|
+
temp_visited = set()
|
|
2918
|
+
order = []
|
|
2919
|
+
|
|
2920
|
+
def visit(cookbook_name: str) -> bool:
|
|
2921
|
+
if cookbook_name in temp_visited:
|
|
2922
|
+
return False # Circular dependency detected
|
|
2923
|
+
if cookbook_name in visited:
|
|
2924
|
+
return True
|
|
2925
|
+
|
|
2926
|
+
temp_visited.add(cookbook_name)
|
|
2927
|
+
|
|
2928
|
+
# Visit all dependencies first
|
|
2929
|
+
for dep in dependency_graph.get(cookbook_name, []):
|
|
2930
|
+
if dep in dependency_graph and not visit(dep):
|
|
2931
|
+
return False
|
|
2932
|
+
|
|
2933
|
+
temp_visited.remove(cookbook_name)
|
|
2934
|
+
visited.add(cookbook_name)
|
|
2935
|
+
order.append(cookbook_name)
|
|
2936
|
+
return True
|
|
2937
|
+
|
|
2938
|
+
# Visit all cookbooks
|
|
2939
|
+
for cookbook_name in dependency_graph:
|
|
2940
|
+
if cookbook_name not in visited and not visit(cookbook_name):
|
|
2941
|
+
break # Circular dependency detected
|
|
2942
|
+
|
|
2943
|
+
return order
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def _build_detailed_migration_order(
|
|
2947
|
+
order: list[str], dependency_graph: dict, individual_results: list
|
|
2948
|
+
) -> list[dict]:
|
|
2949
|
+
"""Build detailed migration order with metadata."""
|
|
2950
|
+
detailed_order = []
|
|
2951
|
+
for i, cookbook_name in enumerate(reversed(order), 1):
|
|
2952
|
+
cookbook_result = next(
|
|
2953
|
+
(r for r in individual_results if r["name"] == cookbook_name), None
|
|
2954
|
+
)
|
|
2955
|
+
if cookbook_result:
|
|
2956
|
+
detailed_order.append(
|
|
2957
|
+
{
|
|
2958
|
+
"phase": i,
|
|
2959
|
+
"cookbook": cookbook_name,
|
|
2960
|
+
"complexity": cookbook_result.get("complexity", "Unknown"),
|
|
2961
|
+
"effort_days": cookbook_result.get("estimated_hours", 0) / 8,
|
|
2962
|
+
"dependencies": dependency_graph.get(cookbook_name, []),
|
|
2963
|
+
"reason": _get_migration_reason(cookbook_name, dependency_graph, i),
|
|
2964
|
+
}
|
|
2965
|
+
)
|
|
2966
|
+
|
|
2967
|
+
return detailed_order
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
def _fallback_migration_order(individual_results: list) -> list[str]:
|
|
2971
|
+
"""Fallback migration order based on complexity (low to high)."""
|
|
2972
|
+
# Sort by complexity score (ascending) and then by dependencies (fewer first)
|
|
2973
|
+
sorted_results = sorted(
|
|
2974
|
+
individual_results,
|
|
2975
|
+
key=lambda x: (
|
|
2976
|
+
{"Low": 0, "Medium": 1, "High": 2}.get(x.get("complexity", "Medium"), 1),
|
|
2977
|
+
x.get("dependencies", 0),
|
|
2978
|
+
),
|
|
2979
|
+
)
|
|
2980
|
+
return [r["name"] for r in sorted_results]
|
|
2981
|
+
|
|
2982
|
+
|
|
2983
|
+
def _get_migration_reason(
|
|
2984
|
+
cookbook_name: str, dependency_graph: dict, phase: int
|
|
2985
|
+
) -> str:
|
|
2986
|
+
"""Get the reason for migrating a cookbook at this phase."""
|
|
2987
|
+
dependencies = dependency_graph.get(cookbook_name, [])
|
|
2988
|
+
|
|
2989
|
+
if not dependencies:
|
|
2990
|
+
return "No dependencies - can be migrated early"
|
|
2991
|
+
elif phase == 1:
|
|
2992
|
+
return "Foundation cookbook with minimal dependencies"
|
|
2993
|
+
else:
|
|
2994
|
+
dep_names = ", ".join(dependencies[:3]) # Show first 3 dependencies
|
|
2995
|
+
if len(dependencies) > 3:
|
|
2996
|
+
dep_names += f" and {len(dependencies) - 3} more"
|
|
2997
|
+
return f"Depends on: {dep_names}"
|
|
2998
|
+
|
|
2999
|
+
|
|
3000
|
+
def _detect_cycle_dependency(
|
|
3001
|
+
dependency_graph: dict, start: str, current: str, path: list[str]
|
|
3002
|
+
) -> list[str] | None:
|
|
3003
|
+
"""Detect a cycle in the dependency graph starting from current node."""
|
|
3004
|
+
if current in path:
|
|
3005
|
+
# Found a cycle
|
|
3006
|
+
cycle_start = path.index(current)
|
|
3007
|
+
return path[cycle_start:] + [current]
|
|
3008
|
+
|
|
3009
|
+
path.append(current)
|
|
3010
|
+
|
|
3011
|
+
for dep in dependency_graph.get(current, []):
|
|
3012
|
+
if dep in dependency_graph: # Only check cookbooks in our project
|
|
3013
|
+
cycle = _detect_cycle_dependency(dependency_graph, start, dep, path)
|
|
3014
|
+
if cycle:
|
|
3015
|
+
return cycle
|
|
3016
|
+
|
|
3017
|
+
path.pop()
|
|
3018
|
+
return None
|
|
3019
|
+
|
|
3020
|
+
|
|
3021
|
+
def _find_circular_dependencies(dependency_graph: dict) -> list[dict]:
|
|
3022
|
+
"""Find circular dependencies in the dependency graph."""
|
|
3023
|
+
circular_deps = []
|
|
3024
|
+
visited = set()
|
|
3025
|
+
|
|
3026
|
+
for cookbook in dependency_graph:
|
|
3027
|
+
if cookbook not in visited:
|
|
3028
|
+
cycle = _detect_cycle_dependency(dependency_graph, cookbook, cookbook, [])
|
|
3029
|
+
if cycle:
|
|
3030
|
+
circular_deps.append(
|
|
3031
|
+
{
|
|
3032
|
+
"cookbooks": cycle,
|
|
3033
|
+
"type": "circular_dependency",
|
|
3034
|
+
"severity": "high",
|
|
3035
|
+
}
|
|
3036
|
+
)
|
|
3037
|
+
# Mark all cycle members as visited to avoid duplicate detection
|
|
3038
|
+
visited.update(cycle)
|
|
3039
|
+
|
|
3040
|
+
return circular_deps
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def _calculate_project_metrics(
|
|
3044
|
+
individual_results: list, dependency_graph: dict
|
|
3045
|
+
) -> dict:
|
|
3046
|
+
"""Calculate project-level complexity and effort metrics."""
|
|
3047
|
+
total_effort = sum(
|
|
3048
|
+
r.get("estimated_hours", 0) / 8 for r in individual_results
|
|
3049
|
+
) # Convert hours to days
|
|
3050
|
+
avg_complexity = (
|
|
3051
|
+
sum(
|
|
3052
|
+
{"Low": 30, "Medium": 50, "High": 80}.get(r.get("complexity", "Medium"), 50)
|
|
3053
|
+
for r in individual_results
|
|
3054
|
+
)
|
|
3055
|
+
/ len(individual_results)
|
|
3056
|
+
if individual_results
|
|
3057
|
+
else 50
|
|
3058
|
+
)
|
|
3059
|
+
|
|
3060
|
+
# Determine project complexity
|
|
3061
|
+
if avg_complexity > 70:
|
|
3062
|
+
project_complexity = "High"
|
|
3063
|
+
elif avg_complexity > 40:
|
|
3064
|
+
project_complexity = "Medium"
|
|
3065
|
+
else:
|
|
3066
|
+
project_complexity = "Low"
|
|
3067
|
+
|
|
3068
|
+
# Determine migration strategy based on dependencies and complexity
|
|
3069
|
+
total_dependencies = sum(len(deps) for deps in dependency_graph.values())
|
|
3070
|
+
has_circular_deps = any(
|
|
3071
|
+
len(dependency_graph.get(cb, [])) > 0 for cb in dependency_graph
|
|
3072
|
+
)
|
|
3073
|
+
|
|
3074
|
+
if project_complexity == "High" or total_dependencies > len(individual_results) * 2:
|
|
3075
|
+
migration_strategy = "phased"
|
|
3076
|
+
elif has_circular_deps:
|
|
3077
|
+
migration_strategy = "parallel"
|
|
3078
|
+
else:
|
|
3079
|
+
migration_strategy = "big_bang"
|
|
3080
|
+
|
|
3081
|
+
# Calculate parallel tracks if needed
|
|
3082
|
+
parallel_tracks = 1
|
|
3083
|
+
if migration_strategy == "parallel":
|
|
3084
|
+
parallel_tracks = min(3, max(2, len(individual_results) // 5))
|
|
3085
|
+
|
|
3086
|
+
# Calculate calendar timeline based on strategy
|
|
3087
|
+
# This applies strategy multipliers (phased +10%, big_bang -10%, parallel +5%)
|
|
3088
|
+
timeline_weeks = get_timeline_weeks(total_effort, strategy=migration_strategy)
|
|
950
3089
|
|
|
3090
|
+
return {
|
|
3091
|
+
"project_complexity": project_complexity,
|
|
3092
|
+
"project_effort_days": round(total_effort, 1),
|
|
3093
|
+
"project_timeline_weeks": timeline_weeks,
|
|
3094
|
+
"migration_strategy": migration_strategy,
|
|
3095
|
+
"parallel_tracks": parallel_tracks,
|
|
3096
|
+
"total_dependencies": total_dependencies,
|
|
3097
|
+
"dependency_density": round(total_dependencies / len(individual_results), 2)
|
|
3098
|
+
if individual_results
|
|
3099
|
+
else 0,
|
|
3100
|
+
}
|
|
951
3101
|
|
|
952
|
-
def _find_cookbook_directory(cookbook_path, cookbook_name):
|
|
953
|
-
"""Find the directory for a specific cookbook."""
|
|
954
|
-
for d in Path(cookbook_path).iterdir():
|
|
955
|
-
if d.is_dir() and d.name == cookbook_name:
|
|
956
|
-
return d
|
|
957
|
-
return None
|
|
958
3102
|
|
|
3103
|
+
def _generate_project_recommendations(
|
|
3104
|
+
project_analysis: dict, individual_results: list
|
|
3105
|
+
) -> list[str]:
|
|
3106
|
+
"""Generate project-level recommendations."""
|
|
3107
|
+
recommendations = []
|
|
959
3108
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
metadata = parse_cookbook_metadata(str(cookbook_dir / METADATA_FILENAME))
|
|
3109
|
+
strategy = project_analysis.get("migration_strategy", "phased")
|
|
3110
|
+
complexity = project_analysis.get("project_complexity", "Medium")
|
|
3111
|
+
effort_days = project_analysis.get("project_effort_days", 0)
|
|
3112
|
+
circular_deps = project_analysis.get("circular_dependencies", [])
|
|
965
3113
|
|
|
966
|
-
|
|
967
|
-
|
|
3114
|
+
# Strategy recommendations
|
|
3115
|
+
if strategy == "phased":
|
|
3116
|
+
recommendations.append(
|
|
3117
|
+
"• Use phased migration approach due to project complexity and dependencies"
|
|
968
3118
|
)
|
|
969
|
-
|
|
970
|
-
|
|
3119
|
+
recommendations.append(
|
|
3120
|
+
"• Start with foundation cookbooks (minimal dependencies) in Phase 1"
|
|
3121
|
+
)
|
|
3122
|
+
recommendations.append("• Migrate dependent cookbooks in subsequent phases")
|
|
3123
|
+
elif strategy == "parallel":
|
|
3124
|
+
tracks = project_analysis.get("parallel_tracks", 2)
|
|
3125
|
+
recommendations.append(
|
|
3126
|
+
f"• Use parallel migration with {tracks} tracks to handle complexity"
|
|
3127
|
+
)
|
|
3128
|
+
recommendations.append("• Assign dedicated teams to each migration track")
|
|
3129
|
+
else:
|
|
3130
|
+
recommendations.append("• Big-bang migration suitable for this project scope")
|
|
971
3131
|
|
|
3132
|
+
# Complexity-based recommendations
|
|
3133
|
+
if complexity == "High":
|
|
3134
|
+
recommendations.append(
|
|
3135
|
+
"• Allocate senior Ansible engineers for complex cookbook conversions"
|
|
3136
|
+
)
|
|
3137
|
+
recommendations.append("• Plan for extensive testing and validation phases")
|
|
3138
|
+
elif complexity == "Medium":
|
|
3139
|
+
recommendations.append(
|
|
3140
|
+
"• Standard engineering team with Ansible experience sufficient"
|
|
3141
|
+
)
|
|
3142
|
+
recommendations.append("• Include peer reviews for quality assurance")
|
|
972
3143
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
"dependencies": len(metadata.get("depends", [])),
|
|
982
|
-
"complexity": assessment.get("complexity", "Unknown"),
|
|
983
|
-
"estimated_hours": assessment.get("estimated_hours", 0),
|
|
984
|
-
"recommendations": assessment.get("recommendations", ""),
|
|
985
|
-
"status": ANALYSIS_STATUS_ANALYSED,
|
|
986
|
-
}
|
|
3144
|
+
# Effort-based recommendations
|
|
3145
|
+
if effort_days > 30:
|
|
3146
|
+
recommendations.append("• Consider extending timeline to reduce team pressure")
|
|
3147
|
+
recommendations.append(
|
|
3148
|
+
"• Break migration into 2-week sprints with deliverables"
|
|
3149
|
+
)
|
|
3150
|
+
else:
|
|
3151
|
+
recommendations.append("• Timeline suitable for focused migration effort")
|
|
987
3152
|
|
|
3153
|
+
# Dependency recommendations
|
|
3154
|
+
dependency_density = project_analysis.get("dependency_density", 0)
|
|
3155
|
+
if dependency_density > 2:
|
|
3156
|
+
recommendations.append(
|
|
3157
|
+
"• High dependency density - prioritize dependency resolution"
|
|
3158
|
+
)
|
|
3159
|
+
recommendations.append("• Create shared Ansible roles for common dependencies")
|
|
988
3160
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
"
|
|
998
|
-
"complexity": "Error",
|
|
999
|
-
"estimated_hours": 0,
|
|
1000
|
-
"recommendations": f"Error: {error_message}",
|
|
1001
|
-
"status": ANALYSIS_STATUS_FAILED,
|
|
1002
|
-
}
|
|
3161
|
+
# Circular dependency warnings
|
|
3162
|
+
if circular_deps:
|
|
3163
|
+
recommendations.append(
|
|
3164
|
+
f"• {len(circular_deps)} circular dependency groups detected"
|
|
3165
|
+
)
|
|
3166
|
+
recommendations.append(
|
|
3167
|
+
"• Resolve circular dependencies before migration begins"
|
|
3168
|
+
)
|
|
3169
|
+
recommendations.append("• Consider refactoring interdependent cookbooks")
|
|
1003
3170
|
|
|
3171
|
+
# Team and resource recommendations
|
|
3172
|
+
total_cookbooks = len(individual_results)
|
|
3173
|
+
if total_cookbooks > 10:
|
|
3174
|
+
recommendations.append(
|
|
3175
|
+
"• Large project scope - consider dedicated migration team"
|
|
3176
|
+
)
|
|
3177
|
+
else:
|
|
3178
|
+
recommendations.append("• Project size manageable with existing team capacity")
|
|
1004
3179
|
|
|
1005
|
-
|
|
1006
|
-
"""Clean up progress indicators."""
|
|
1007
|
-
progress_bar.empty()
|
|
1008
|
-
status_text.empty()
|
|
3180
|
+
return recommendations
|
|
1009
3181
|
|
|
1010
3182
|
|
|
1011
3183
|
def _display_analysis_results(results, total_cookbooks):
|
|
1012
3184
|
"""Display the complete analysis results."""
|
|
3185
|
+
# Display stored analysis info messages if available
|
|
3186
|
+
if "analysis_info_messages" in st.session_state:
|
|
3187
|
+
for message in st.session_state.analysis_info_messages:
|
|
3188
|
+
st.info(message)
|
|
3189
|
+
st.success(
|
|
3190
|
+
f"✓ Analysis completed! Analysed {len(results)} cookbook(s) with "
|
|
3191
|
+
f"detailed AI insights."
|
|
3192
|
+
)
|
|
3193
|
+
|
|
1013
3194
|
# Add a back button to return to analysis selection
|
|
1014
|
-
col1,
|
|
3195
|
+
col1, _ = st.columns([1, 4])
|
|
1015
3196
|
with col1:
|
|
1016
|
-
if st.button(
|
|
3197
|
+
if st.button(
|
|
3198
|
+
"Analyse More Cookbooks",
|
|
3199
|
+
help="Return to cookbook selection",
|
|
3200
|
+
key="analyse_more",
|
|
3201
|
+
):
|
|
1017
3202
|
# Clear session state to go back to selection
|
|
1018
3203
|
st.session_state.analysis_results = None
|
|
1019
3204
|
st.session_state.analysis_cookbook_path = None
|
|
1020
3205
|
st.session_state.total_cookbooks = 0
|
|
3206
|
+
st.session_state.project_analysis = None
|
|
1021
3207
|
# Clean up temporary directory when going back
|
|
1022
3208
|
if st.session_state.temp_dir and st.session_state.temp_dir.exists():
|
|
1023
3209
|
shutil.rmtree(st.session_state.temp_dir, ignore_errors=True)
|
|
1024
3210
|
st.session_state.temp_dir = None
|
|
1025
3211
|
st.rerun()
|
|
1026
3212
|
|
|
1027
|
-
|
|
1028
|
-
st.subheader("Analysis Results")
|
|
3213
|
+
st.subheader("Analysis Results")
|
|
1029
3214
|
|
|
1030
3215
|
_display_analysis_summary(results, total_cookbooks)
|
|
3216
|
+
|
|
3217
|
+
# Display project-level analysis if available
|
|
3218
|
+
if "project_analysis" in st.session_state and st.session_state.project_analysis:
|
|
3219
|
+
_display_project_analysis(st.session_state.project_analysis)
|
|
3220
|
+
|
|
1031
3221
|
_display_results_table(results)
|
|
1032
3222
|
_display_detailed_analysis(results)
|
|
1033
3223
|
_display_download_option(results)
|
|
1034
3224
|
|
|
1035
3225
|
|
|
3226
|
+
def _display_project_analysis(project_analysis: dict):
|
|
3227
|
+
"""Display project-level analysis results."""
|
|
3228
|
+
st.subheader("Project-Level Analysis")
|
|
3229
|
+
|
|
3230
|
+
# Project metrics
|
|
3231
|
+
col1, col2, col3, col4 = st.columns(4)
|
|
3232
|
+
|
|
3233
|
+
with col1:
|
|
3234
|
+
st.metric(
|
|
3235
|
+
"Project Complexity", project_analysis.get("project_complexity", "Unknown")
|
|
3236
|
+
)
|
|
3237
|
+
|
|
3238
|
+
with col2:
|
|
3239
|
+
effort_days = project_analysis.get("project_effort_days", 0)
|
|
3240
|
+
timeline_weeks = project_analysis.get("project_timeline_weeks", 2)
|
|
3241
|
+
effort_hours = effort_days * 8
|
|
3242
|
+
st.metric(
|
|
3243
|
+
"Total Effort",
|
|
3244
|
+
f"{effort_hours:.0f} hours ({timeline_weeks} weeks calendar time)",
|
|
3245
|
+
)
|
|
3246
|
+
|
|
3247
|
+
with col3:
|
|
3248
|
+
strategy = (
|
|
3249
|
+
project_analysis.get("migration_strategy", "phased")
|
|
3250
|
+
.replace("_", " ")
|
|
3251
|
+
.title()
|
|
3252
|
+
)
|
|
3253
|
+
st.metric("Migration Strategy", strategy)
|
|
3254
|
+
|
|
3255
|
+
with col4:
|
|
3256
|
+
dependencies = project_analysis.get("total_dependencies", 0)
|
|
3257
|
+
st.metric("Total Dependencies", dependencies)
|
|
3258
|
+
|
|
3259
|
+
# Migration order
|
|
3260
|
+
if project_analysis.get("migration_order"):
|
|
3261
|
+
st.subheader("Recommended Migration Order")
|
|
3262
|
+
|
|
3263
|
+
migration_df = pd.DataFrame(project_analysis["migration_order"])
|
|
3264
|
+
migration_df = migration_df.rename(
|
|
3265
|
+
columns={
|
|
3266
|
+
"phase": "Phase",
|
|
3267
|
+
"cookbook": "Cookbook",
|
|
3268
|
+
"complexity": "Complexity",
|
|
3269
|
+
"effort_days": "Effort (Days)",
|
|
3270
|
+
"dependencies": "Dependencies",
|
|
3271
|
+
"reason": "Migration Reason",
|
|
3272
|
+
}
|
|
3273
|
+
)
|
|
3274
|
+
|
|
3275
|
+
st.dataframe(migration_df, width="stretch")
|
|
3276
|
+
|
|
3277
|
+
# Dependency graph visualization
|
|
3278
|
+
if project_analysis.get("dependency_graph"):
|
|
3279
|
+
with st.expander("Dependency Graph"):
|
|
3280
|
+
_display_dependency_graph(project_analysis["dependency_graph"])
|
|
3281
|
+
|
|
3282
|
+
# Circular dependencies warning
|
|
3283
|
+
if project_analysis.get("circular_dependencies"):
|
|
3284
|
+
st.warning("Circular Dependencies Detected")
|
|
3285
|
+
for circ in project_analysis["circular_dependencies"]:
|
|
3286
|
+
cookbooks = " → ".join(circ["cookbooks"])
|
|
3287
|
+
st.write(f"**Cycle:** {cookbooks}")
|
|
3288
|
+
|
|
3289
|
+
# Effort explanation
|
|
3290
|
+
with st.expander("Effort vs Timeline"):
|
|
3291
|
+
effort_days = project_analysis.get("project_effort_days", 0)
|
|
3292
|
+
effort_hours = effort_days * 8
|
|
3293
|
+
timeline_weeks = project_analysis.get("project_timeline_weeks", 2)
|
|
3294
|
+
strategy = (
|
|
3295
|
+
project_analysis.get("migration_strategy", "phased")
|
|
3296
|
+
.replace("_", " ")
|
|
3297
|
+
.title()
|
|
3298
|
+
)
|
|
3299
|
+
explanation = (
|
|
3300
|
+
f"**Effort**: {effort_hours:.0f} hours ({effort_days:.1f} person-days) "
|
|
3301
|
+
f"of actual work\n\n"
|
|
3302
|
+
f"**Calendar Timeline**: {timeline_weeks} weeks\n\n"
|
|
3303
|
+
f"**Strategy**: {strategy}\n\n"
|
|
3304
|
+
f"The difference between effort and timeline accounts for:\n"
|
|
3305
|
+
f"• Phased approach adds ~10% overhead for testing between phases\n"
|
|
3306
|
+
f"• Parallel execution allows some tasks to overlap\n"
|
|
3307
|
+
f"• Dependency constraints may extend the critical path\n"
|
|
3308
|
+
f"• Team coordination and integration time"
|
|
3309
|
+
)
|
|
3310
|
+
st.write(explanation)
|
|
3311
|
+
|
|
3312
|
+
# Project recommendations
|
|
3313
|
+
if project_analysis.get("recommendations"):
|
|
3314
|
+
with st.expander("Project Recommendations"):
|
|
3315
|
+
for rec in project_analysis["recommendations"]:
|
|
3316
|
+
st.write(rec)
|
|
3317
|
+
|
|
3318
|
+
|
|
3319
|
+
def _display_dependency_graph(dependency_graph: dict):
|
|
3320
|
+
"""Display a visual representation of the dependency graph."""
|
|
3321
|
+
st.write("**Cookbook Dependencies:**")
|
|
3322
|
+
|
|
3323
|
+
for cookbook, deps in dependency_graph.items():
|
|
3324
|
+
if deps:
|
|
3325
|
+
deps_str = ", ".join(deps)
|
|
3326
|
+
st.write(f"• **{cookbook}** depends on: {deps_str}")
|
|
3327
|
+
else:
|
|
3328
|
+
st.write(f"• **{cookbook}** (no dependencies)")
|
|
3329
|
+
|
|
3330
|
+
# Show dependency statistics
|
|
3331
|
+
total_deps = sum(len(deps) for deps in dependency_graph.values())
|
|
3332
|
+
cookbooks_with_deps = sum(1 for deps in dependency_graph.values() if deps)
|
|
3333
|
+
isolated_cookbooks = len(dependency_graph) - cookbooks_with_deps
|
|
3334
|
+
|
|
3335
|
+
st.write(f"""
|
|
3336
|
+
**Dependency Statistics:**
|
|
3337
|
+
- Total dependencies: {total_deps}
|
|
3338
|
+
- Cookbooks with dependencies: {cookbooks_with_deps}
|
|
3339
|
+
- Independent cookbooks: {isolated_cookbooks}
|
|
3340
|
+
- Average dependencies per cookbook: {total_deps / len(dependency_graph):.1f}
|
|
3341
|
+
""")
|
|
3342
|
+
|
|
3343
|
+
|
|
1036
3344
|
def _display_download_option(results):
|
|
1037
3345
|
"""Display download options for analysis results."""
|
|
1038
3346
|
st.subheader("Download Options")
|
|
@@ -1062,6 +3370,7 @@ def _display_download_option(results):
|
|
|
1062
3370
|
"Convert to Ansible Playbooks",
|
|
1063
3371
|
type="primary",
|
|
1064
3372
|
help="Convert analysed cookbooks to Ansible playbooks and download as ZIP",
|
|
3373
|
+
key="convert_to_ansible_playbooks",
|
|
1065
3374
|
):
|
|
1066
3375
|
# Check AI configuration status
|
|
1067
3376
|
ai_config = load_ai_settings()
|
|
@@ -1074,10 +3383,10 @@ def _display_download_option(results):
|
|
|
1074
3383
|
if ai_available:
|
|
1075
3384
|
provider = ai_config.get("provider", "Unknown")
|
|
1076
3385
|
model = ai_config.get("model", "Unknown")
|
|
1077
|
-
st.info(f"
|
|
3386
|
+
st.info(f"Using AI-enhanced conversion with {provider} ({model})")
|
|
1078
3387
|
else:
|
|
1079
3388
|
st.info(
|
|
1080
|
-
"
|
|
3389
|
+
"Using deterministic conversion. Configure AI settings "
|
|
1081
3390
|
"for enhanced results."
|
|
1082
3391
|
)
|
|
1083
3392
|
|
|
@@ -1114,26 +3423,127 @@ def _display_detailed_analysis(results):
|
|
|
1114
3423
|
"""Display detailed analysis for each cookbook."""
|
|
1115
3424
|
st.subheader("Detailed Analysis")
|
|
1116
3425
|
|
|
1117
|
-
for
|
|
1118
|
-
|
|
3426
|
+
successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
3427
|
+
failed_results = [r for r in results if r["status"] == ANALYSIS_STATUS_FAILED]
|
|
3428
|
+
|
|
3429
|
+
if successful_results:
|
|
3430
|
+
st.markdown("### Successfully Analysed Cookbooks")
|
|
3431
|
+
for result in successful_results:
|
|
1119
3432
|
_display_single_cookbook_details(result)
|
|
1120
3433
|
|
|
3434
|
+
if failed_results:
|
|
3435
|
+
st.markdown("### Failed Analysis Cookbooks")
|
|
3436
|
+
for result in failed_results:
|
|
3437
|
+
_display_failed_cookbook_details(result)
|
|
1121
3438
|
|
|
1122
|
-
def _display_single_cookbook_details(result):
|
|
1123
|
-
"""Display detailed analysis for a single cookbook."""
|
|
1124
|
-
with st.expander(f"{result['name']} - {result['complexity']} Complexity"):
|
|
1125
|
-
col1, col2 = st.columns(2)
|
|
1126
3439
|
|
|
3440
|
+
def _validate_cookbook_structure(cookbook_dir: Path) -> dict:
|
|
3441
|
+
"""Validate the basic structure of a cookbook for analysis."""
|
|
3442
|
+
validation = {}
|
|
3443
|
+
|
|
3444
|
+
# Check if directory exists
|
|
3445
|
+
validation["Cookbook directory exists"] = (
|
|
3446
|
+
cookbook_dir.exists() and cookbook_dir.is_dir()
|
|
3447
|
+
)
|
|
3448
|
+
|
|
3449
|
+
if not validation["Cookbook directory exists"]:
|
|
3450
|
+
return validation
|
|
3451
|
+
|
|
3452
|
+
# Check metadata.rb
|
|
3453
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
3454
|
+
validation["metadata.rb exists"] = metadata_file.exists()
|
|
3455
|
+
|
|
3456
|
+
# Check recipes directory
|
|
3457
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
3458
|
+
validation["recipes/ directory exists"] = (
|
|
3459
|
+
recipes_dir.exists() and recipes_dir.is_dir()
|
|
3460
|
+
)
|
|
3461
|
+
|
|
3462
|
+
if validation["recipes/ directory exists"]:
|
|
3463
|
+
recipe_files = list(recipes_dir.glob("*.rb"))
|
|
3464
|
+
validation["Has recipe files"] = len(recipe_files) > 0
|
|
3465
|
+
validation["Has default.rb recipe"] = (recipes_dir / "default.rb").exists()
|
|
3466
|
+
else:
|
|
3467
|
+
validation["Has recipe files"] = False
|
|
3468
|
+
validation["Has default.rb recipe"] = False
|
|
3469
|
+
|
|
3470
|
+
# Check for common cookbook directories
|
|
3471
|
+
common_dirs = ["attributes", "templates", "files", "libraries", "definitions"]
|
|
3472
|
+
for dir_name in common_dirs:
|
|
3473
|
+
dir_path = cookbook_dir / dir_name
|
|
3474
|
+
validation[f"{dir_name}/ directory exists"] = (
|
|
3475
|
+
dir_path.exists() and dir_path.is_dir()
|
|
3476
|
+
)
|
|
3477
|
+
|
|
3478
|
+
return validation
|
|
3479
|
+
|
|
3480
|
+
|
|
3481
|
+
def _display_single_cookbook_details(result):
|
|
3482
|
+
"""Display detailed information for a successfully analysed cookbook."""
|
|
3483
|
+
with st.expander(f"{result['name']} - Analysis Complete", expanded=True):
|
|
3484
|
+
# Basic information
|
|
3485
|
+
col1, col2, col3 = st.columns(3)
|
|
1127
3486
|
with col1:
|
|
1128
|
-
st.
|
|
1129
|
-
|
|
1130
|
-
st.
|
|
3487
|
+
st.metric("Version", result.get("version", "Unknown"))
|
|
3488
|
+
with col2:
|
|
3489
|
+
st.metric("Maintainer", result.get("maintainer", "Unknown"))
|
|
3490
|
+
with col3:
|
|
3491
|
+
st.metric("Dependencies", result.get("dependencies", 0))
|
|
1131
3492
|
|
|
3493
|
+
# Complexity and effort
|
|
3494
|
+
col1, col2 = st.columns(2)
|
|
3495
|
+
with col1:
|
|
3496
|
+
complexity = result.get("complexity", "Unknown")
|
|
3497
|
+
if complexity == "High":
|
|
3498
|
+
st.metric("Complexity", complexity, delta="High")
|
|
3499
|
+
elif complexity == "Medium":
|
|
3500
|
+
st.metric("Complexity", complexity, delta="Medium")
|
|
3501
|
+
else:
|
|
3502
|
+
st.metric("Complexity", complexity, delta="Low")
|
|
1132
3503
|
with col2:
|
|
1133
|
-
|
|
1134
|
-
st.
|
|
3504
|
+
hours = result.get("estimated_hours", 0)
|
|
3505
|
+
st.metric("Estimated Hours", f"{hours:.1f}")
|
|
3506
|
+
|
|
3507
|
+
# Path
|
|
3508
|
+
st.write(f"**Cookbook Path:** {result['path']}")
|
|
3509
|
+
|
|
3510
|
+
# Recommendations
|
|
3511
|
+
if result.get("recommendations"):
|
|
3512
|
+
st.markdown("**Analysis Recommendations:**")
|
|
3513
|
+
st.info(result["recommendations"])
|
|
3514
|
+
|
|
3515
|
+
|
|
3516
|
+
def _display_failed_cookbook_details(result):
|
|
3517
|
+
"""Display detailed failure information for a cookbook."""
|
|
3518
|
+
with st.expander(f"{result['name']} - Analysis Failed", expanded=True):
|
|
3519
|
+
st.error(f"**Analysis Error:** {result['recommendations']}")
|
|
1135
3520
|
|
|
1136
|
-
|
|
3521
|
+
# Show cookbook path
|
|
3522
|
+
st.write(f"**Cookbook Path:** {result['path']}")
|
|
3523
|
+
|
|
3524
|
+
# Try to show some basic validation info
|
|
3525
|
+
cookbook_dir = Path(result["path"])
|
|
3526
|
+
validation_info = _validate_cookbook_structure(cookbook_dir)
|
|
3527
|
+
|
|
3528
|
+
if validation_info:
|
|
3529
|
+
st.markdown("**Cookbook Structure Validation:**")
|
|
3530
|
+
for check, status in validation_info.items():
|
|
3531
|
+
icon = "✓" if status else "✗"
|
|
3532
|
+
st.write(f"{icon} {check}")
|
|
3533
|
+
|
|
3534
|
+
# Suggest fixes
|
|
3535
|
+
st.markdown("**Suggested Fixes:**")
|
|
3536
|
+
st.markdown("""
|
|
3537
|
+
- Check if `metadata.rb` exists and is valid Ruby syntax
|
|
3538
|
+
- Ensure `recipes/` directory exists with at least one `.rb` file
|
|
3539
|
+
- Verify cookbook dependencies are properly declared
|
|
3540
|
+
- Check for syntax errors in recipe files
|
|
3541
|
+
- Ensure the cookbook follows standard Chef structure
|
|
3542
|
+
""")
|
|
3543
|
+
|
|
3544
|
+
# Show raw error details in a collapsible section
|
|
3545
|
+
with st.expander("Technical Error Details"):
|
|
3546
|
+
st.code(result["recommendations"], language="text")
|
|
1137
3547
|
|
|
1138
3548
|
|
|
1139
3549
|
def _convert_and_download_playbooks(results):
|
|
@@ -1144,21 +3554,42 @@ def _convert_and_download_playbooks(results):
|
|
|
1144
3554
|
st.warning("No successfully analysed cookbooks to convert.")
|
|
1145
3555
|
return
|
|
1146
3556
|
|
|
3557
|
+
# Get project recommendations from session state
|
|
3558
|
+
project_recommendations = None
|
|
3559
|
+
if "project_analysis" in st.session_state and st.session_state.project_analysis:
|
|
3560
|
+
project_recommendations = st.session_state.project_analysis
|
|
3561
|
+
|
|
1147
3562
|
with st.spinner("Converting cookbooks to Ansible playbooks..."):
|
|
1148
3563
|
playbooks = []
|
|
3564
|
+
templates = []
|
|
1149
3565
|
|
|
1150
3566
|
for result in successful_results:
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
3567
|
+
# _convert_single_cookbook now returns tuple of (playbooks, templates)
|
|
3568
|
+
cookbook_playbooks, cookbook_templates = _convert_single_cookbook(
|
|
3569
|
+
result, project_recommendations
|
|
3570
|
+
)
|
|
3571
|
+
if cookbook_playbooks:
|
|
3572
|
+
playbooks.extend(cookbook_playbooks)
|
|
3573
|
+
if cookbook_templates:
|
|
3574
|
+
templates.extend(cookbook_templates)
|
|
3575
|
+
|
|
3576
|
+
st.info(
|
|
3577
|
+
f"Total: {len(playbooks)} playbook(s) and {len(templates)} "
|
|
3578
|
+
f"template(s) ready for download"
|
|
3579
|
+
)
|
|
1154
3580
|
|
|
1155
3581
|
if playbooks:
|
|
1156
3582
|
# Save converted playbooks to temporary directory for validation
|
|
1157
3583
|
try:
|
|
1158
3584
|
output_dir = Path(tempfile.mkdtemp(prefix="souschef_converted_"))
|
|
1159
|
-
|
|
1160
|
-
#
|
|
1161
|
-
|
|
3585
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
3586
|
+
output_dir.chmod(0o700) # Secure permissions: rwx------
|
|
3587
|
+
for _i, playbook in enumerate(playbooks):
|
|
3588
|
+
# Sanitize filename - include recipe name to avoid conflicts
|
|
3589
|
+
recipe_name = playbook["recipe_file"].replace(".rb", "")
|
|
3590
|
+
cookbook_name = _sanitize_filename(playbook["cookbook_name"])
|
|
3591
|
+
recipe_name = _sanitize_filename(recipe_name)
|
|
3592
|
+
filename = f"{cookbook_name}_{recipe_name}.yml"
|
|
1162
3593
|
(output_dir / filename).write_text(playbook["playbook_content"])
|
|
1163
3594
|
|
|
1164
3595
|
# Store path in session state for validation page
|
|
@@ -1167,72 +3598,171 @@ def _convert_and_download_playbooks(results):
|
|
|
1167
3598
|
except Exception as e:
|
|
1168
3599
|
st.warning(f"Could not stage playbooks for validation: {e}")
|
|
1169
3600
|
|
|
1170
|
-
|
|
3601
|
+
# Store conversion results in session state to persist across reruns
|
|
3602
|
+
st.session_state.conversion_results = {
|
|
3603
|
+
"playbooks": playbooks,
|
|
3604
|
+
"templates": templates,
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
_handle_playbook_download(playbooks, templates)
|
|
3608
|
+
|
|
1171
3609
|
|
|
3610
|
+
def _convert_single_cookbook(
|
|
3611
|
+
result: dict, project_recommendations: dict | None = None
|
|
3612
|
+
) -> tuple[list, list]:
|
|
3613
|
+
"""
|
|
3614
|
+
Convert entire cookbook (all recipes) to Ansible playbooks.
|
|
3615
|
+
|
|
3616
|
+
Args:
|
|
3617
|
+
result: Cookbook analysis result.
|
|
3618
|
+
project_recommendations: Optional project recommendations.
|
|
3619
|
+
|
|
3620
|
+
Returns:
|
|
3621
|
+
Tuple of (playbooks list, templates list).
|
|
1172
3622
|
|
|
1173
|
-
|
|
1174
|
-
"""Convert a single cookbook to Ansible playbook."""
|
|
3623
|
+
"""
|
|
1175
3624
|
cookbook_dir = Path(result["path"])
|
|
1176
|
-
|
|
3625
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
1177
3626
|
|
|
1178
|
-
if not
|
|
1179
|
-
|
|
3627
|
+
if not recipes_dir.exists():
|
|
3628
|
+
st.warning(f"No recipes directory found in {result['name']}")
|
|
3629
|
+
return [], []
|
|
1180
3630
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
ai_config.get("provider")
|
|
1186
|
-
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
1187
|
-
and ai_config.get("api_key")
|
|
1188
|
-
)
|
|
3631
|
+
recipe_files = list(recipes_dir.glob("*.rb"))
|
|
3632
|
+
if not recipe_files:
|
|
3633
|
+
st.warning(f"No recipe files found in {result['name']}")
|
|
3634
|
+
return [], []
|
|
1189
3635
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
3636
|
+
# Convert recipes
|
|
3637
|
+
converted_playbooks = _convert_recipes(
|
|
3638
|
+
result["name"], recipe_files, project_recommendations
|
|
3639
|
+
)
|
|
3640
|
+
|
|
3641
|
+
# Convert templates
|
|
3642
|
+
converted_templates = _convert_templates(result["name"], cookbook_dir)
|
|
3643
|
+
|
|
3644
|
+
return converted_playbooks, converted_templates
|
|
3645
|
+
|
|
3646
|
+
|
|
3647
|
+
def _convert_recipes(
|
|
3648
|
+
cookbook_name: str, recipe_files: list, project_recommendations: dict | None
|
|
3649
|
+
) -> list:
|
|
3650
|
+
"""
|
|
3651
|
+
Convert all recipes in a cookbook.
|
|
3652
|
+
|
|
3653
|
+
Args:
|
|
3654
|
+
cookbook_name: Name of the cookbook.
|
|
3655
|
+
recipe_files: List of recipe file paths.
|
|
3656
|
+
project_recommendations: Optional project recommendations.
|
|
3657
|
+
|
|
3658
|
+
Returns:
|
|
3659
|
+
List of converted playbooks.
|
|
3660
|
+
|
|
3661
|
+
"""
|
|
3662
|
+
ai_config = load_ai_settings()
|
|
3663
|
+
provider_name = _get_ai_provider(ai_config)
|
|
3664
|
+
use_ai = (
|
|
3665
|
+
provider_name and provider_name != LOCAL_PROVIDER and ai_config.get("api_key")
|
|
3666
|
+
)
|
|
3667
|
+
|
|
3668
|
+
provider_mapping = {
|
|
3669
|
+
ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
|
|
3670
|
+
ANTHROPIC_PROVIDER: "anthropic",
|
|
3671
|
+
"OpenAI": "openai",
|
|
3672
|
+
OPENAI_PROVIDER: "openai",
|
|
3673
|
+
IBM_WATSONX: "watson",
|
|
3674
|
+
RED_HAT_LIGHTSPEED: "lightspeed",
|
|
3675
|
+
}
|
|
3676
|
+
ai_provider = provider_mapping.get(
|
|
3677
|
+
provider_name,
|
|
3678
|
+
provider_name.lower().replace(" ", "_") if provider_name else "anthropic",
|
|
3679
|
+
)
|
|
3680
|
+
|
|
3681
|
+
converted_playbooks = []
|
|
3682
|
+
api_key = _get_ai_string_value(ai_config, "api_key", "")
|
|
3683
|
+
model = _get_ai_string_value(ai_config, "model", "claude-3-5-sonnet-20241022")
|
|
3684
|
+
temperature = _get_ai_float_value(ai_config, "temperature", 0.7)
|
|
3685
|
+
max_tokens = _get_ai_int_value(ai_config, "max_tokens", 4000)
|
|
3686
|
+
project_id = _get_ai_string_value(ai_config, "project_id", "")
|
|
3687
|
+
base_url = _get_ai_string_value(ai_config, "base_url", "")
|
|
1205
3688
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
3689
|
+
for recipe_file in recipe_files:
|
|
3690
|
+
try:
|
|
3691
|
+
if use_ai:
|
|
3692
|
+
playbook_content = generate_playbook_from_recipe_with_ai(
|
|
3693
|
+
str(recipe_file),
|
|
3694
|
+
ai_provider=ai_provider,
|
|
3695
|
+
api_key=api_key,
|
|
3696
|
+
model=model,
|
|
3697
|
+
temperature=temperature,
|
|
3698
|
+
max_tokens=max_tokens,
|
|
3699
|
+
project_id=project_id,
|
|
3700
|
+
base_url=base_url,
|
|
3701
|
+
project_recommendations=project_recommendations,
|
|
3702
|
+
)
|
|
3703
|
+
else:
|
|
3704
|
+
playbook_content = generate_playbook_from_recipe(str(recipe_file))
|
|
3705
|
+
|
|
3706
|
+
if not playbook_content.startswith("Error"):
|
|
3707
|
+
converted_playbooks.append(
|
|
3708
|
+
{
|
|
3709
|
+
"cookbook_name": cookbook_name,
|
|
3710
|
+
"playbook_content": playbook_content,
|
|
3711
|
+
"recipe_file": recipe_file.name,
|
|
3712
|
+
"conversion_method": "AI-enhanced"
|
|
3713
|
+
if use_ai
|
|
3714
|
+
else "Deterministic",
|
|
3715
|
+
}
|
|
3716
|
+
)
|
|
3717
|
+
else:
|
|
3718
|
+
st.warning(f"Failed to convert {recipe_file.name}: {playbook_content}")
|
|
3719
|
+
except Exception as e:
|
|
3720
|
+
st.warning(f"Failed to convert {recipe_file.name}: {e}")
|
|
3721
|
+
|
|
3722
|
+
return converted_playbooks
|
|
3723
|
+
|
|
3724
|
+
|
|
3725
|
+
def _convert_templates(cookbook_name: str, cookbook_dir: Path) -> list:
|
|
3726
|
+
"""
|
|
3727
|
+
Convert all templates in a cookbook.
|
|
3728
|
+
|
|
3729
|
+
Args:
|
|
3730
|
+
cookbook_name: Name of the cookbook.
|
|
3731
|
+
cookbook_dir: Path to cookbook directory.
|
|
3732
|
+
|
|
3733
|
+
Returns:
|
|
3734
|
+
List of converted templates.
|
|
3735
|
+
|
|
3736
|
+
"""
|
|
3737
|
+
converted_templates = []
|
|
3738
|
+
template_results = convert_cookbook_templates(str(cookbook_dir))
|
|
3739
|
+
|
|
3740
|
+
if template_results.get("success"):
|
|
3741
|
+
for template_result in template_results.get("results", []):
|
|
3742
|
+
if template_result["success"]:
|
|
3743
|
+
converted_templates.append(
|
|
3744
|
+
{
|
|
3745
|
+
"cookbook_name": cookbook_name,
|
|
3746
|
+
"template_content": template_result["jinja2_content"],
|
|
3747
|
+
"template_file": Path(template_result["jinja2_file"]).name,
|
|
3748
|
+
"original_file": Path(template_result["original_file"]).name,
|
|
3749
|
+
"variables": template_result["variables"],
|
|
3750
|
+
}
|
|
3751
|
+
)
|
|
3752
|
+
if converted_templates:
|
|
3753
|
+
st.info(
|
|
3754
|
+
f"Converted {len(converted_templates)} template(s) from {cookbook_name}"
|
|
1215
3755
|
)
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
return {
|
|
1222
|
-
"cookbook_name": result["name"],
|
|
1223
|
-
"playbook_content": playbook_content,
|
|
1224
|
-
"recipe_file": recipe_file.name,
|
|
1225
|
-
"conversion_method": "AI-enhanced" if use_ai else "Deterministic",
|
|
1226
|
-
}
|
|
1227
|
-
else:
|
|
1228
|
-
st.warning(f"Failed to convert {result['name']}: {playbook_content}")
|
|
1229
|
-
return None
|
|
1230
|
-
except Exception as e:
|
|
1231
|
-
st.warning(f"Failed to convert {result['name']}: {e}")
|
|
1232
|
-
return None
|
|
3756
|
+
elif not template_results.get("message"):
|
|
3757
|
+
st.warning(
|
|
3758
|
+
f"Template conversion failed for {cookbook_name}: "
|
|
3759
|
+
f"{template_results.get('error', 'Unknown error')}"
|
|
3760
|
+
)
|
|
1233
3761
|
|
|
3762
|
+
return converted_templates
|
|
1234
3763
|
|
|
1235
|
-
|
|
3764
|
+
|
|
3765
|
+
def _find_recipe_file(cookbook_dir: Path, cookbook_name: str) -> Path | None:
|
|
1236
3766
|
"""Find the appropriate recipe file for a cookbook."""
|
|
1237
3767
|
recipes_dir = cookbook_dir / "recipes"
|
|
1238
3768
|
if not recipes_dir.exists():
|
|
@@ -1249,85 +3779,402 @@ def _find_recipe_file(cookbook_dir, cookbook_name):
|
|
|
1249
3779
|
return default_recipe if default_recipe.exists() else recipe_files[0]
|
|
1250
3780
|
|
|
1251
3781
|
|
|
1252
|
-
def _handle_playbook_download(playbooks):
|
|
3782
|
+
def _handle_playbook_download(playbooks: list, templates: list | None = None) -> None:
|
|
1253
3783
|
"""Handle the download of generated playbooks."""
|
|
1254
3784
|
if not playbooks:
|
|
1255
3785
|
st.error("No playbooks were successfully generated.")
|
|
1256
3786
|
return
|
|
1257
3787
|
|
|
1258
|
-
#
|
|
1259
|
-
|
|
3788
|
+
# Add back to analysis button
|
|
3789
|
+
col1, _ = st.columns([1, 4])
|
|
3790
|
+
with col1:
|
|
3791
|
+
if st.button(
|
|
3792
|
+
"← Back to Analysis",
|
|
3793
|
+
help="Return to analysis results",
|
|
3794
|
+
key="back_to_analysis_from_conversion",
|
|
3795
|
+
):
|
|
3796
|
+
# Clear conversion results to go back to analysis view
|
|
3797
|
+
st.session_state.conversion_results = None
|
|
3798
|
+
st.session_state.generated_playbook_repo = None
|
|
3799
|
+
st.rerun()
|
|
3800
|
+
|
|
3801
|
+
templates = templates or []
|
|
3802
|
+
playbook_archive = _create_playbook_archive(playbooks, templates)
|
|
1260
3803
|
|
|
3804
|
+
# Display success and statistics
|
|
3805
|
+
unique_cookbooks = len({p["cookbook_name"] for p in playbooks})
|
|
3806
|
+
template_count = len(templates)
|
|
1261
3807
|
st.success(
|
|
1262
|
-
f"Successfully converted {
|
|
3808
|
+
f"Successfully converted {unique_cookbooks} cookbook(s) with "
|
|
3809
|
+
f"{len(playbooks)} recipe(s) and {template_count} template(s) to Ansible!"
|
|
3810
|
+
)
|
|
3811
|
+
|
|
3812
|
+
# Show summary
|
|
3813
|
+
_display_playbook_summary(len(playbooks), template_count)
|
|
3814
|
+
|
|
3815
|
+
# Provide download button and repository creation
|
|
3816
|
+
_display_download_button(
|
|
3817
|
+
len(playbooks), template_count, playbook_archive, playbooks
|
|
3818
|
+
)
|
|
3819
|
+
|
|
3820
|
+
# Show previews
|
|
3821
|
+
_display_playbook_previews(playbooks)
|
|
3822
|
+
_display_template_previews(templates)
|
|
3823
|
+
|
|
3824
|
+
|
|
3825
|
+
def _display_playbook_summary(playbook_count: int, template_count: int) -> None:
|
|
3826
|
+
"""Display summary of archive contents."""
|
|
3827
|
+
if template_count > 0:
|
|
3828
|
+
st.info(
|
|
3829
|
+
f"Archive includes:\n"
|
|
3830
|
+
f"- {playbook_count} playbook files (.yml)\n"
|
|
3831
|
+
f"- {template_count} template files (.j2)\n"
|
|
3832
|
+
f"- README.md with conversion details"
|
|
3833
|
+
)
|
|
3834
|
+
else:
|
|
3835
|
+
st.info(
|
|
3836
|
+
f"Archive includes:\n"
|
|
3837
|
+
f"- {playbook_count} playbook files (.yml)\n"
|
|
3838
|
+
f"- README.md with conversion details\n"
|
|
3839
|
+
f"- Note: No templates were found in the converted cookbooks"
|
|
3840
|
+
)
|
|
3841
|
+
|
|
3842
|
+
|
|
3843
|
+
def _build_download_label(playbook_count: int, template_count: int) -> str:
|
|
3844
|
+
"""Build the download button label."""
|
|
3845
|
+
label = f"Download Ansible Playbooks ({playbook_count} playbooks"
|
|
3846
|
+
if template_count > 0:
|
|
3847
|
+
label += f", {template_count} templates"
|
|
3848
|
+
label += ")"
|
|
3849
|
+
return label
|
|
3850
|
+
|
|
3851
|
+
|
|
3852
|
+
def _write_playbooks_to_temp_dir(playbooks: list, temp_dir: str) -> None:
|
|
3853
|
+
"""Write playbooks to temporary directory."""
|
|
3854
|
+
for playbook in playbooks:
|
|
3855
|
+
cookbook_name = _sanitize_filename(playbook["cookbook_name"])
|
|
3856
|
+
recipe_name = _sanitize_filename(playbook["recipe_file"].replace(".rb", ""))
|
|
3857
|
+
playbook_file = Path(temp_dir) / f"{cookbook_name}_{recipe_name}.yml"
|
|
3858
|
+
playbook_file.write_text(playbook["playbook_content"])
|
|
3859
|
+
|
|
3860
|
+
|
|
3861
|
+
def _get_playbooks_dir(repo_result: dict) -> Path:
|
|
3862
|
+
"""Get or create the playbooks directory in the repository."""
|
|
3863
|
+
playbooks_dir = Path(repo_result["temp_path"]) / "playbooks"
|
|
3864
|
+
if not playbooks_dir.exists():
|
|
3865
|
+
playbooks_dir = (
|
|
3866
|
+
Path(repo_result["temp_path"])
|
|
3867
|
+
/ "ansible_collections"
|
|
3868
|
+
/ "souschef"
|
|
3869
|
+
/ "platform"
|
|
3870
|
+
/ "playbooks"
|
|
3871
|
+
)
|
|
3872
|
+
playbooks_dir.mkdir(parents=True, exist_ok=True)
|
|
3873
|
+
return playbooks_dir
|
|
3874
|
+
|
|
3875
|
+
|
|
3876
|
+
def _copy_playbooks_to_repo(temp_dir: str, playbooks_dir: Path) -> None:
|
|
3877
|
+
"""Copy playbooks from temp directory to repository."""
|
|
3878
|
+
for playbook_file in Path(temp_dir).glob("*.yml"):
|
|
3879
|
+
shutil.copy(playbook_file, playbooks_dir / playbook_file.name)
|
|
3880
|
+
|
|
3881
|
+
|
|
3882
|
+
def _commit_playbooks_to_git(temp_dir: str, repo_path: str) -> None:
|
|
3883
|
+
"""Commit playbooks to git repository."""
|
|
3884
|
+
try:
|
|
3885
|
+
subprocess.run(
|
|
3886
|
+
["git", "add", "."],
|
|
3887
|
+
cwd=repo_path,
|
|
3888
|
+
check=True,
|
|
3889
|
+
capture_output=True,
|
|
3890
|
+
text=True,
|
|
3891
|
+
)
|
|
3892
|
+
num_playbooks = len(list(Path(temp_dir).glob("*.yml")))
|
|
3893
|
+
commit_msg = f"Add converted Ansible playbooks ({num_playbooks} playbook(s))"
|
|
3894
|
+
subprocess.run(
|
|
3895
|
+
["git", "commit", "-m", commit_msg],
|
|
3896
|
+
cwd=repo_path,
|
|
3897
|
+
check=True,
|
|
3898
|
+
capture_output=True,
|
|
3899
|
+
text=True,
|
|
3900
|
+
)
|
|
3901
|
+
except subprocess.CalledProcessError:
|
|
3902
|
+
# If there's nothing to commit, that's okay
|
|
3903
|
+
pass
|
|
3904
|
+
|
|
3905
|
+
|
|
3906
|
+
def _handle_repo_creation(temp_dir: str, playbooks: list) -> None:
|
|
3907
|
+
"""Handle repository creation and setup."""
|
|
3908
|
+
repo_result = _create_ansible_repository(
|
|
3909
|
+
output_path=temp_dir,
|
|
3910
|
+
cookbook_path="",
|
|
3911
|
+
num_roles=len({p["cookbook_name"] for p in playbooks}),
|
|
3912
|
+
)
|
|
3913
|
+
|
|
3914
|
+
if not repo_result["success"]:
|
|
3915
|
+
st.error(
|
|
3916
|
+
f"Failed to create repository: {repo_result.get('error', 'Unknown error')}"
|
|
3917
|
+
)
|
|
3918
|
+
return
|
|
3919
|
+
|
|
3920
|
+
playbooks_dir = _get_playbooks_dir(repo_result)
|
|
3921
|
+
_copy_playbooks_to_repo(temp_dir, playbooks_dir)
|
|
3922
|
+
_commit_playbooks_to_git(temp_dir, repo_result["temp_path"])
|
|
3923
|
+
st.session_state.generated_playbook_repo = repo_result
|
|
3924
|
+
|
|
3925
|
+
|
|
3926
|
+
def _display_repo_structure_section(repo_result: dict) -> None:
|
|
3927
|
+
"""Display repository structure in an expander."""
|
|
3928
|
+
with st.expander("Repository Structure", expanded=True):
|
|
3929
|
+
files_sorted = sorted(repo_result["files_created"])
|
|
3930
|
+
st.code("\n".join(files_sorted[:40]), language="text")
|
|
3931
|
+
if len(files_sorted) > 40:
|
|
3932
|
+
remaining = len(files_sorted) - 40
|
|
3933
|
+
st.caption(f"... and {remaining} more files")
|
|
3934
|
+
|
|
3935
|
+
|
|
3936
|
+
def _display_repo_info_section(repo_result: dict) -> None:
|
|
3937
|
+
"""Display repository information."""
|
|
3938
|
+
repo_type = repo_result["repo_type"].replace("_", " ").title()
|
|
3939
|
+
st.info(
|
|
3940
|
+
f"**Repository Type:** {repo_type}\n\n"
|
|
3941
|
+
f"**Files Created:** {len(repo_result['files_created'])}\n\n"
|
|
3942
|
+
"Includes: ansible.cfg, requirements.yml, inventory, playbooks"
|
|
1263
3943
|
)
|
|
1264
3944
|
|
|
1265
|
-
|
|
3945
|
+
|
|
3946
|
+
def _display_generated_repo_section_internal(repo_result: dict) -> None:
|
|
3947
|
+
"""Display the complete generated repository section."""
|
|
3948
|
+
st.markdown("---")
|
|
3949
|
+
st.success("Ansible Playbook Repository Generated!")
|
|
3950
|
+
_display_repo_info_section(repo_result)
|
|
3951
|
+
_display_repo_structure_section(repo_result)
|
|
3952
|
+
|
|
3953
|
+
repo_zip = _create_repository_zip(repo_result["temp_path"])
|
|
1266
3954
|
st.download_button(
|
|
1267
|
-
label="Download Ansible
|
|
1268
|
-
data=
|
|
1269
|
-
file_name="
|
|
1270
|
-
mime=
|
|
1271
|
-
help="Download
|
|
3955
|
+
label="Download Ansible Repository",
|
|
3956
|
+
data=repo_zip,
|
|
3957
|
+
file_name="ansible_playbook_repository.zip",
|
|
3958
|
+
mime=MIME_TYPE_ZIP,
|
|
3959
|
+
help="Download complete Ansible repository as ZIP archive",
|
|
3960
|
+
key="download_playbook_repo",
|
|
1272
3961
|
)
|
|
1273
3962
|
|
|
1274
|
-
|
|
1275
|
-
|
|
3963
|
+
with st.expander("Git Clone Instructions", expanded=True):
|
|
3964
|
+
st.markdown("""
|
|
3965
|
+
After downloading and extracting the repository:
|
|
3966
|
+
|
|
3967
|
+
```bash
|
|
3968
|
+
cd ansible_playbook_repository
|
|
3969
|
+
|
|
3970
|
+
# Repository is already initialized with git!
|
|
3971
|
+
# Check commits:
|
|
3972
|
+
git log --oneline
|
|
3973
|
+
|
|
3974
|
+
# Push to remote repository:
|
|
3975
|
+
git remote add origin <your-git-url>
|
|
3976
|
+
git push -u origin master
|
|
3977
|
+
```
|
|
3978
|
+
|
|
3979
|
+
**What's included:**
|
|
3980
|
+
- ✅ Ansible configuration (`ansible.cfg`)
|
|
3981
|
+
- ✅ Dependency management (`requirements.yml`)
|
|
3982
|
+
- ✅ Inventory structure
|
|
3983
|
+
- ✅ All converted playbooks
|
|
3984
|
+
- ✅ `.gitignore` for Ansible projects
|
|
3985
|
+
- ✅ `.gitattributes` for consistent line endings
|
|
3986
|
+
- ✅ `.editorconfig` for consistent coding styles
|
|
3987
|
+
- ✅ README with usage instructions
|
|
3988
|
+
- ✅ **Git repository initialized with all files committed**
|
|
3989
|
+
""")
|
|
3990
|
+
|
|
3991
|
+
if st.button("Clear Repository", key="clear_playbook_repo"):
|
|
3992
|
+
if "generated_playbook_repo" in st.session_state:
|
|
3993
|
+
with contextlib.suppress(Exception):
|
|
3994
|
+
shutil.rmtree(repo_result["temp_path"])
|
|
3995
|
+
del st.session_state.generated_playbook_repo
|
|
3996
|
+
st.rerun()
|
|
3997
|
+
|
|
3998
|
+
|
|
3999
|
+
def _display_download_button(
|
|
4000
|
+
playbook_count: int,
|
|
4001
|
+
template_count: int,
|
|
4002
|
+
archive_data: bytes,
|
|
4003
|
+
playbooks: list | None = None,
|
|
4004
|
+
) -> None:
|
|
4005
|
+
"""Display the download button for the archive."""
|
|
4006
|
+
download_label = _build_download_label(playbook_count, template_count)
|
|
4007
|
+
|
|
4008
|
+
col1, col2 = st.columns([1, 1])
|
|
4009
|
+
|
|
4010
|
+
with col1:
|
|
4011
|
+
st.download_button(
|
|
4012
|
+
label=download_label,
|
|
4013
|
+
data=archive_data,
|
|
4014
|
+
file_name="ansible_playbooks.zip",
|
|
4015
|
+
mime=MIME_TYPE_ZIP,
|
|
4016
|
+
help=f"Download ZIP archive containing {playbook_count} playbooks "
|
|
4017
|
+
f"and {template_count} templates",
|
|
4018
|
+
key="download_playbooks_archive",
|
|
4019
|
+
)
|
|
4020
|
+
|
|
4021
|
+
with col2:
|
|
4022
|
+
if st.button(
|
|
4023
|
+
"Create Ansible Repository",
|
|
4024
|
+
help=(
|
|
4025
|
+
"Generate a complete Ansible repository structure with these playbooks"
|
|
4026
|
+
),
|
|
4027
|
+
key="create_repo_from_playbooks",
|
|
4028
|
+
):
|
|
4029
|
+
with st.spinner("Creating Ansible repository with playbooks..."):
|
|
4030
|
+
temp_playbook_dir = tempfile.mkdtemp(prefix="playbooks_")
|
|
4031
|
+
if playbooks:
|
|
4032
|
+
_write_playbooks_to_temp_dir(playbooks, temp_playbook_dir)
|
|
4033
|
+
_handle_repo_creation(temp_playbook_dir, playbooks)
|
|
4034
|
+
|
|
4035
|
+
# Display generated repository options for playbooks
|
|
4036
|
+
if "generated_playbook_repo" in st.session_state:
|
|
4037
|
+
_display_generated_repo_section_internal(
|
|
4038
|
+
st.session_state.generated_playbook_repo
|
|
4039
|
+
)
|
|
4040
|
+
|
|
4041
|
+
|
|
4042
|
+
def _display_playbook_previews(playbooks: list) -> None:
|
|
4043
|
+
"""Display preview of generated playbooks."""
|
|
4044
|
+
with st.expander("Preview Generated Playbooks", expanded=True):
|
|
1276
4045
|
for playbook in playbooks:
|
|
1277
4046
|
conversion_badge = (
|
|
1278
|
-
"
|
|
4047
|
+
"AI-Enhanced"
|
|
1279
4048
|
if playbook.get("conversion_method") == "AI-enhanced"
|
|
1280
|
-
else "
|
|
4049
|
+
else "Deterministic"
|
|
1281
4050
|
)
|
|
1282
4051
|
st.subheader(
|
|
1283
4052
|
f"{playbook['cookbook_name']} ({conversion_badge}) - "
|
|
1284
4053
|
f"from {playbook['recipe_file']}"
|
|
1285
4054
|
)
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
4055
|
+
content = playbook["playbook_content"]
|
|
4056
|
+
preview = content[:1000] + "..." if len(content) > 1000 else content
|
|
4057
|
+
st.code(preview, language="yaml")
|
|
4058
|
+
st.divider()
|
|
4059
|
+
|
|
4060
|
+
|
|
4061
|
+
def _display_template_previews(templates: list) -> None:
|
|
4062
|
+
"""Display preview of converted templates."""
|
|
4063
|
+
if not templates:
|
|
4064
|
+
return
|
|
4065
|
+
|
|
4066
|
+
with st.expander(
|
|
4067
|
+
f"Preview Converted Templates ({len(templates)} templates)", expanded=True
|
|
4068
|
+
):
|
|
4069
|
+
for template in templates:
|
|
4070
|
+
st.subheader(
|
|
4071
|
+
f"{template['cookbook_name']}/templates/{template['template_file']}"
|
|
1291
4072
|
)
|
|
4073
|
+
st.caption(f"Converted from: {template['original_file']}")
|
|
4074
|
+
|
|
4075
|
+
# Show extracted variables
|
|
4076
|
+
if template.get("variables"):
|
|
4077
|
+
with st.container():
|
|
4078
|
+
st.write("**Variables used in template:**")
|
|
4079
|
+
st.code(", ".join(template["variables"]), language="text")
|
|
4080
|
+
|
|
4081
|
+
# Show template content preview
|
|
4082
|
+
content = template["template_content"]
|
|
4083
|
+
preview = content[:500] + "..." if len(content) > 500 else content
|
|
4084
|
+
st.code(preview, language="jinja2")
|
|
1292
4085
|
st.divider()
|
|
1293
4086
|
|
|
1294
4087
|
|
|
1295
|
-
def _create_playbook_archive(playbooks):
|
|
1296
|
-
"""Create a ZIP archive containing all generated Ansible playbooks."""
|
|
4088
|
+
def _create_playbook_archive(playbooks, templates=None):
|
|
4089
|
+
"""Create a ZIP archive containing all generated Ansible playbooks and templates."""
|
|
1297
4090
|
zip_buffer = io.BytesIO()
|
|
4091
|
+
templates = templates or []
|
|
1298
4092
|
|
|
1299
4093
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
1300
|
-
#
|
|
4094
|
+
# Organize playbooks by cookbook in subdirectories
|
|
1301
4095
|
for playbook in playbooks:
|
|
1302
|
-
|
|
4096
|
+
# Create cookbook directory structure with sanitised names
|
|
4097
|
+
cookbook_name = _sanitize_filename(playbook["cookbook_name"])
|
|
4098
|
+
recipe_name = _sanitize_filename(playbook["recipe_file"].replace(".rb", ""))
|
|
4099
|
+
playbook_filename = f"{cookbook_name}/{recipe_name}.yml"
|
|
1303
4100
|
zip_file.writestr(playbook_filename, playbook["playbook_content"])
|
|
1304
4101
|
|
|
4102
|
+
# Add converted templates
|
|
4103
|
+
for template in templates:
|
|
4104
|
+
cookbook_name = _sanitize_filename(template["cookbook_name"])
|
|
4105
|
+
template_filename = _sanitize_filename(template["template_file"])
|
|
4106
|
+
archive_path = f"{cookbook_name}/templates/{template_filename}"
|
|
4107
|
+
zip_file.writestr(archive_path, template["template_content"])
|
|
4108
|
+
|
|
4109
|
+
# Count unique cookbooks
|
|
4110
|
+
unique_cookbooks = len({p["cookbook_name"] for p in playbooks})
|
|
4111
|
+
template_count = len(templates)
|
|
4112
|
+
|
|
1305
4113
|
# Add a summary README
|
|
1306
4114
|
readme_content = f"""# Ansible Playbooks Generated by SousChef
|
|
1307
4115
|
|
|
1308
|
-
This archive contains {len(playbooks)} Ansible playbooks
|
|
4116
|
+
This archive contains {len(playbooks)} Ansible playbooks and {template_count} """
|
|
4117
|
+
readme_content += f"templates from {unique_cookbooks} cookbook(s) "
|
|
4118
|
+
readme_content += "converted from Chef."
|
|
4119
|
+
|
|
4120
|
+
readme_content += """
|
|
1309
4121
|
|
|
1310
4122
|
## Contents:
|
|
1311
4123
|
"""
|
|
1312
4124
|
|
|
4125
|
+
# Group by cookbook for README
|
|
4126
|
+
from collections import defaultdict
|
|
4127
|
+
|
|
4128
|
+
by_cookbook = defaultdict(list)
|
|
1313
4129
|
for playbook in playbooks:
|
|
1314
|
-
|
|
4130
|
+
by_cookbook[playbook["cookbook_name"]].append(playbook)
|
|
4131
|
+
|
|
4132
|
+
# Group templates by cookbook
|
|
4133
|
+
by_cookbook_templates = defaultdict(list)
|
|
4134
|
+
for template in templates:
|
|
4135
|
+
by_cookbook_templates[template["cookbook_name"]].append(template)
|
|
4136
|
+
|
|
4137
|
+
for cookbook_name, cookbook_playbooks in sorted(by_cookbook.items()):
|
|
4138
|
+
cookbook_templates = by_cookbook_templates.get(cookbook_name, [])
|
|
4139
|
+
# Sanitise cookbook name for display in README
|
|
4140
|
+
safe_cookbook_name = _sanitize_filename(cookbook_name)
|
|
1315
4141
|
readme_content += (
|
|
1316
|
-
f"
|
|
1317
|
-
f"(
|
|
1318
|
-
f"
|
|
4142
|
+
f"\n### {safe_cookbook_name}/ "
|
|
4143
|
+
f"({len(cookbook_playbooks)} recipes, "
|
|
4144
|
+
f"{len(cookbook_templates)} templates)\n"
|
|
1319
4145
|
)
|
|
4146
|
+
for playbook in cookbook_playbooks:
|
|
4147
|
+
conversion_method = playbook.get("conversion_method", "Deterministic")
|
|
4148
|
+
recipe_name = playbook["recipe_file"].replace(".rb", "")
|
|
4149
|
+
safe_recipe_name = _sanitize_filename(recipe_name)
|
|
4150
|
+
readme_content += (
|
|
4151
|
+
f" - {safe_recipe_name}.yml "
|
|
4152
|
+
f"(from {playbook['recipe_file']}, "
|
|
4153
|
+
f"{conversion_method})\n"
|
|
4154
|
+
)
|
|
4155
|
+
if cookbook_templates:
|
|
4156
|
+
readme_content += " - templates/\n"
|
|
4157
|
+
for template in cookbook_templates:
|
|
4158
|
+
safe_template_name = _sanitize_filename(template["template_file"])
|
|
4159
|
+
readme_content += (
|
|
4160
|
+
f" - {safe_template_name} "
|
|
4161
|
+
f"(from {template['original_file']})\n"
|
|
4162
|
+
)
|
|
1320
4163
|
|
|
1321
4164
|
readme_content += """
|
|
1322
4165
|
|
|
1323
4166
|
## Usage:
|
|
1324
4167
|
Run these playbooks with Ansible:
|
|
1325
|
-
ansible-playbook <
|
|
4168
|
+
ansible-playbook <cookbook_name>/<recipe_name>.yml
|
|
1326
4169
|
|
|
1327
4170
|
## Notes:
|
|
1328
4171
|
- These playbooks were automatically generated from Chef recipes
|
|
4172
|
+
- Templates have been converted from ERB to Jinja2 format
|
|
4173
|
+
- Each cookbook's recipes and templates are organized in separate directories
|
|
4174
|
+
- Review and test before deploying to production
|
|
1329
4175
|
- Review and test the playbooks before using in production
|
|
1330
|
-
- Some manual adjustments may be required for complex recipes
|
|
4176
|
+
- Some manual adjustments may be required for complex recipes or templates
|
|
4177
|
+
- Verify that template variables are correctly mapped from Chef to Ansible
|
|
1331
4178
|
"""
|
|
1332
4179
|
|
|
1333
4180
|
zip_file.writestr("README.md", readme_content)
|
|
@@ -1354,7 +4201,3 @@ def _create_analysis_report(results):
|
|
|
1354
4201
|
}
|
|
1355
4202
|
|
|
1356
4203
|
return json.dumps(report, indent=2)
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
if __name__ == "__main__":
|
|
1360
|
-
show_cookbook_analysis_page()
|