mcp-souschef 2.5.3__py3-none-any.whl → 2.8.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.5.3.dist-info → mcp_souschef-2.8.0.dist-info}/METADATA +56 -21
- mcp_souschef-2.8.0.dist-info/RECORD +42 -0
- souschef/__init__.py +10 -2
- souschef/assessment.py +14 -14
- souschef/ci/github_actions.py +5 -5
- souschef/ci/gitlab_ci.py +4 -4
- souschef/ci/jenkins_pipeline.py +4 -4
- souschef/cli.py +12 -12
- souschef/converters/__init__.py +2 -2
- souschef/converters/cookbook_specific.py +125 -0
- souschef/converters/cookbook_specific.py.backup +109 -0
- souschef/converters/playbook.py +853 -15
- souschef/converters/resource.py +103 -1
- souschef/core/constants.py +13 -0
- souschef/core/path_utils.py +12 -9
- souschef/deployment.py +24 -24
- souschef/parsers/attributes.py +397 -32
- souschef/parsers/recipe.py +48 -10
- souschef/server.py +35 -37
- souschef/ui/app.py +1413 -252
- souschef/ui/health_check.py +36 -0
- souschef/ui/pages/ai_settings.py +497 -0
- souschef/ui/pages/cookbook_analysis.py +1010 -75
- mcp_souschef-2.5.3.dist-info/RECORD +0 -38
- {mcp_souschef-2.5.3.dist-info → mcp_souschef-2.8.0.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.5.3.dist-info → mcp_souschef-2.8.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.5.3.dist-info → mcp_souschef-2.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"""Cookbook Analysis Page for SousChef UI."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
3
6
|
import sys
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
4
10
|
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
5
12
|
|
|
6
13
|
import pandas as pd # type: ignore[import-untyped]
|
|
7
14
|
import streamlit as st
|
|
@@ -10,41 +17,564 @@ import streamlit as st
|
|
|
10
17
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
11
18
|
|
|
12
19
|
from souschef.assessment import parse_chef_migration_assessment
|
|
20
|
+
from souschef.converters.playbook import (
|
|
21
|
+
generate_playbook_from_recipe,
|
|
22
|
+
generate_playbook_from_recipe_with_ai,
|
|
23
|
+
)
|
|
24
|
+
from souschef.core.constants import METADATA_FILENAME
|
|
13
25
|
from souschef.parsers.metadata import parse_cookbook_metadata
|
|
14
26
|
|
|
27
|
+
# AI Settings
|
|
28
|
+
ANTHROPIC_PROVIDER = "Anthropic (Claude)"
|
|
29
|
+
OPENAI_PROVIDER = "OpenAI (GPT)"
|
|
30
|
+
LOCAL_PROVIDER = "Local Model"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_ai_settings():
|
|
34
|
+
"""Load AI settings from configuration file."""
|
|
35
|
+
try:
|
|
36
|
+
# Use /tmp/.souschef for container compatibility (tmpfs is writable)
|
|
37
|
+
config_file = Path("/tmp/.souschef/ai_config.json")
|
|
38
|
+
if config_file.exists():
|
|
39
|
+
with config_file.open() as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass # Ignore errors when loading config file; return empty dict as fallback
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
15
46
|
# Constants for repeated strings
|
|
16
47
|
METADATA_STATUS_YES = "Yes"
|
|
17
48
|
METADATA_STATUS_NO = "No"
|
|
18
|
-
|
|
49
|
+
ANALYSIS_STATUS_ANALYSED = "Analysed"
|
|
19
50
|
ANALYSIS_STATUS_FAILED = "Failed"
|
|
20
51
|
METADATA_COLUMN_NAME = "Has Metadata"
|
|
21
52
|
|
|
53
|
+
# Security limits for archive extraction
|
|
54
|
+
MAX_ARCHIVE_SIZE = 100 * 1024 * 1024 # 100MB total
|
|
55
|
+
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB per file
|
|
56
|
+
MAX_FILES = 1000 # Maximum number of files
|
|
57
|
+
MAX_DEPTH = 10 # Maximum directory depth
|
|
58
|
+
BLOCKED_EXTENSIONS = {
|
|
59
|
+
".exe",
|
|
60
|
+
".bat",
|
|
61
|
+
".cmd",
|
|
62
|
+
".com",
|
|
63
|
+
".pif",
|
|
64
|
+
".scr",
|
|
65
|
+
".vbs",
|
|
66
|
+
".js",
|
|
67
|
+
".jar",
|
|
68
|
+
# Note: .sh files are allowed as they are common in Chef cookbooks
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_archive(uploaded_file) -> tuple[Path, Path]:
|
|
73
|
+
"""
|
|
74
|
+
Extract uploaded archive to a temporary directory with security checks.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
tuple: (temp_dir_path, cookbook_root_path)
|
|
78
|
+
|
|
79
|
+
Implements multiple security measures to prevent:
|
|
80
|
+
- Zip bombs (size limits, file count limits)
|
|
81
|
+
- Path traversal attacks (../ validation)
|
|
82
|
+
- Resource exhaustion (depth limits, size limits)
|
|
83
|
+
- Malicious files (symlinks, executables blocked)
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
# Check initial file size
|
|
87
|
+
file_size = len(uploaded_file.getbuffer())
|
|
88
|
+
if file_size > MAX_ARCHIVE_SIZE:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Archive too large: {file_size} bytes (max: {MAX_ARCHIVE_SIZE})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Create temporary directory (will be cleaned up by caller)
|
|
94
|
+
temp_dir = Path(tempfile.mkdtemp())
|
|
95
|
+
temp_path = temp_dir
|
|
96
|
+
|
|
97
|
+
# Save uploaded file
|
|
98
|
+
archive_path = temp_path / uploaded_file.name
|
|
99
|
+
with archive_path.open("wb") as f:
|
|
100
|
+
f.write(uploaded_file.getbuffer())
|
|
101
|
+
|
|
102
|
+
# Extract archive with security checks
|
|
103
|
+
extraction_dir = temp_path / "extracted"
|
|
104
|
+
extraction_dir.mkdir()
|
|
105
|
+
|
|
106
|
+
_extract_archive_by_type(archive_path, extraction_dir, uploaded_file.name)
|
|
107
|
+
|
|
108
|
+
# Find the root directory (should contain cookbooks)
|
|
109
|
+
cookbook_root = _determine_cookbook_root(extraction_dir)
|
|
110
|
+
|
|
111
|
+
return temp_dir, cookbook_root
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _extract_archive_by_type(
|
|
115
|
+
archive_path: Path, extraction_dir: Path, filename: str
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Extract archive based on file extension."""
|
|
118
|
+
if filename.endswith(".zip"):
|
|
119
|
+
_extract_zip_securely(archive_path, extraction_dir)
|
|
120
|
+
elif filename.endswith((".tar.gz", ".tgz")):
|
|
121
|
+
_extract_tar_securely(archive_path, extraction_dir, gzipped=True)
|
|
122
|
+
elif filename.endswith(".tar"):
|
|
123
|
+
_extract_tar_securely(archive_path, extraction_dir, gzipped=False)
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError(f"Unsupported archive format: {filename}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _determine_cookbook_root(extraction_dir: Path) -> Path:
|
|
129
|
+
"""Determine the root directory containing cookbooks."""
|
|
130
|
+
subdirs = [d for d in extraction_dir.iterdir() if d.is_dir()]
|
|
131
|
+
|
|
132
|
+
# Check if this looks like a single cookbook archive (contains typical
|
|
133
|
+
# cookbook dirs)
|
|
134
|
+
cookbook_dirs = {
|
|
135
|
+
"recipes",
|
|
136
|
+
"attributes",
|
|
137
|
+
"templates",
|
|
138
|
+
"files",
|
|
139
|
+
"libraries",
|
|
140
|
+
"definitions",
|
|
141
|
+
}
|
|
142
|
+
extracted_dirs = {d.name for d in subdirs}
|
|
143
|
+
|
|
144
|
+
cookbook_root = extraction_dir
|
|
145
|
+
|
|
146
|
+
if len(subdirs) > 1 and cookbook_dirs.intersection(extracted_dirs):
|
|
147
|
+
# Case 1: Multiple cookbook directories at root level
|
|
148
|
+
cookbook_root = _handle_multiple_cookbook_dirs(extraction_dir, subdirs)
|
|
149
|
+
elif len(subdirs) == 1:
|
|
150
|
+
# Case 2: Single directory - check if it contains cookbook components
|
|
151
|
+
cookbook_root = _handle_single_cookbook_dir(
|
|
152
|
+
extraction_dir, subdirs[0], cookbook_dirs
|
|
153
|
+
)
|
|
154
|
+
# else: Multiple directories that are not cookbook components - use extraction_dir
|
|
155
|
+
|
|
156
|
+
return cookbook_root
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _handle_multiple_cookbook_dirs(extraction_dir: Path, subdirs: list) -> Path:
|
|
160
|
+
"""Handle case where multiple cookbook directories are at root level."""
|
|
161
|
+
synthetic_cookbook_dir = extraction_dir / "cookbook"
|
|
162
|
+
synthetic_cookbook_dir.mkdir(exist_ok=True)
|
|
163
|
+
|
|
164
|
+
# Move all extracted directories into the synthetic cookbook
|
|
165
|
+
for subdir in subdirs:
|
|
166
|
+
if subdir.name in {
|
|
167
|
+
"recipes",
|
|
168
|
+
"attributes",
|
|
169
|
+
"templates",
|
|
170
|
+
"files",
|
|
171
|
+
"libraries",
|
|
172
|
+
"definitions",
|
|
173
|
+
}:
|
|
174
|
+
shutil.move(str(subdir), str(synthetic_cookbook_dir / subdir.name))
|
|
175
|
+
|
|
176
|
+
# Create a basic metadata.rb file
|
|
177
|
+
metadata_content = """name 'extracted_cookbook'
|
|
178
|
+
maintainer 'SousChef'
|
|
179
|
+
maintainer_email 'souschef@example.com'
|
|
180
|
+
license 'All rights reserved'
|
|
181
|
+
description 'Automatically extracted cookbook from archive'
|
|
182
|
+
version '1.0.0'
|
|
183
|
+
"""
|
|
184
|
+
(synthetic_cookbook_dir / METADATA_FILENAME).write_text(metadata_content)
|
|
185
|
+
|
|
186
|
+
return extraction_dir
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _handle_single_cookbook_dir(
|
|
190
|
+
extraction_dir: Path, single_dir: Path, cookbook_dirs: set
|
|
191
|
+
) -> Path:
|
|
192
|
+
"""Handle case where single directory contains cookbook components."""
|
|
193
|
+
single_dir_contents = {d.name for d in single_dir.iterdir() if d.is_dir()}
|
|
194
|
+
|
|
195
|
+
if cookbook_dirs.intersection(single_dir_contents):
|
|
196
|
+
# This single directory contains cookbook components - treat it as a cookbook
|
|
197
|
+
# Check if it already has metadata.rb
|
|
198
|
+
if not (single_dir / METADATA_FILENAME).exists():
|
|
199
|
+
# Create synthetic metadata.rb
|
|
200
|
+
metadata_content = f"""name '{single_dir.name}'
|
|
201
|
+
maintainer 'SousChef'
|
|
202
|
+
maintainer_email 'souschef@example.com'
|
|
203
|
+
license 'All rights reserved'
|
|
204
|
+
description 'Automatically extracted cookbook from archive'
|
|
205
|
+
version '1.0.0'
|
|
206
|
+
"""
|
|
207
|
+
(single_dir / METADATA_FILENAME).write_text(metadata_content)
|
|
208
|
+
|
|
209
|
+
return extraction_dir
|
|
210
|
+
else:
|
|
211
|
+
# Single directory that doesn't contain cookbook components
|
|
212
|
+
return single_dir
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_zip_securely(archive_path: Path, extraction_dir: Path) -> None:
|
|
216
|
+
"""Extract ZIP archive with security checks."""
|
|
217
|
+
total_size = 0
|
|
218
|
+
|
|
219
|
+
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
|
220
|
+
# Pre-scan for security issues
|
|
221
|
+
for file_count, info in enumerate(zip_ref.filelist, start=1):
|
|
222
|
+
_validate_zip_file_security(info, file_count, total_size)
|
|
223
|
+
total_size += info.file_size
|
|
224
|
+
|
|
225
|
+
# Safe extraction with manual path handling
|
|
226
|
+
for info in zip_ref.filelist:
|
|
227
|
+
# Construct safe relative path
|
|
228
|
+
safe_path = _get_safe_extraction_path(info.filename, extraction_dir)
|
|
229
|
+
|
|
230
|
+
if info.is_dir():
|
|
231
|
+
# Create directory
|
|
232
|
+
safe_path.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
else:
|
|
234
|
+
# Create parent directories if needed
|
|
235
|
+
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
# Extract file content manually
|
|
237
|
+
with zip_ref.open(info) as source, safe_path.open("wb") as target:
|
|
238
|
+
# Read in chunks to control memory usage
|
|
239
|
+
while True:
|
|
240
|
+
chunk = source.read(8192)
|
|
241
|
+
if not chunk:
|
|
242
|
+
break
|
|
243
|
+
target.write(chunk)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _validate_zip_file_security(info, file_count: int, total_size: int) -> None:
|
|
247
|
+
"""Validate a single ZIP file entry for security issues."""
|
|
248
|
+
file_count += 1
|
|
249
|
+
if file_count > MAX_FILES:
|
|
250
|
+
raise ValueError(f"Too many files in archive: {file_count} (max: {MAX_FILES})")
|
|
251
|
+
|
|
252
|
+
# Check file size
|
|
253
|
+
if info.file_size > MAX_FILE_SIZE:
|
|
254
|
+
raise ValueError(f"File too large: {info.filename} ({info.file_size} bytes)")
|
|
255
|
+
|
|
256
|
+
total_size += info.file_size
|
|
257
|
+
if total_size > MAX_ARCHIVE_SIZE:
|
|
258
|
+
raise ValueError(f"Total archive size too large: {total_size} bytes")
|
|
259
|
+
|
|
260
|
+
# Check for path traversal
|
|
261
|
+
if _has_path_traversal(info.filename):
|
|
262
|
+
raise ValueError(f"Path traversal detected: {info.filename}")
|
|
263
|
+
|
|
264
|
+
# Check directory depth
|
|
265
|
+
if _exceeds_depth_limit(info.filename):
|
|
266
|
+
raise ValueError(f"Directory depth too deep: {info.filename}")
|
|
267
|
+
|
|
268
|
+
# Check for blocked file extensions
|
|
269
|
+
if _is_blocked_extension(info.filename):
|
|
270
|
+
raise ValueError(f"Blocked file type: {info.filename}")
|
|
271
|
+
|
|
272
|
+
# Check for symlinks
|
|
273
|
+
if _is_symlink(info):
|
|
274
|
+
raise ValueError(f"Symlinks not allowed: {info.filename}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extract_tar_securely(
|
|
278
|
+
archive_path: Path, extraction_dir: Path, gzipped: bool
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Extract TAR archive with security checks."""
|
|
281
|
+
mode = "r:gz" if gzipped else "r"
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
with tarfile.open(str(archive_path), mode=mode) as tar_ref: # type: ignore[call-overload]
|
|
285
|
+
members = tar_ref.getmembers()
|
|
286
|
+
_pre_scan_tar_members(members)
|
|
287
|
+
_extract_tar_members(tar_ref, members, extraction_dir)
|
|
288
|
+
except tarfile.TarError as e:
|
|
289
|
+
raise ValueError(f"Invalid or corrupted TAR archive: {e}") from e
|
|
290
|
+
except Exception as e:
|
|
291
|
+
raise ValueError(f"Failed to process TAR archive: {e}") from e
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _pre_scan_tar_members(members):
|
|
295
|
+
"""Pre-scan TAR members for security issues and accumulate totals."""
|
|
296
|
+
total_size = 0
|
|
297
|
+
for file_count, member in enumerate(members, start=1):
|
|
298
|
+
total_size += member.size
|
|
299
|
+
_validate_tar_file_security(member, file_count, total_size)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _extract_tar_members(tar_ref, members, extraction_dir):
|
|
303
|
+
"""Extract validated TAR members to the extraction directory."""
|
|
304
|
+
for member in members:
|
|
305
|
+
safe_path = _get_safe_extraction_path(member.name, extraction_dir)
|
|
306
|
+
if member.isdir():
|
|
307
|
+
safe_path.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
else:
|
|
309
|
+
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
_extract_file_content(tar_ref, member, safe_path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _extract_file_content(tar_ref, member, safe_path):
|
|
314
|
+
"""Extract the content of a single TAR member to a file."""
|
|
315
|
+
source = tar_ref.extractfile(member)
|
|
316
|
+
if source:
|
|
317
|
+
with source, safe_path.open("wb") as target:
|
|
318
|
+
while True:
|
|
319
|
+
chunk = source.read(8192)
|
|
320
|
+
if not chunk:
|
|
321
|
+
break
|
|
322
|
+
target.write(chunk)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_tar_file_security(member, file_count: int, total_size: int) -> None:
|
|
326
|
+
"""Validate a single TAR file entry for security issues."""
|
|
327
|
+
file_count += 1
|
|
328
|
+
if file_count > MAX_FILES:
|
|
329
|
+
raise ValueError(f"Too many files in archive: {file_count} (max: {MAX_FILES})")
|
|
330
|
+
|
|
331
|
+
# Check file size
|
|
332
|
+
if member.size > MAX_FILE_SIZE:
|
|
333
|
+
raise ValueError(f"File too large: {member.name} ({member.size} bytes)")
|
|
334
|
+
|
|
335
|
+
total_size += member.size
|
|
336
|
+
if total_size > MAX_ARCHIVE_SIZE:
|
|
337
|
+
raise ValueError(f"Total archive size too large: {total_size} bytes")
|
|
338
|
+
|
|
339
|
+
# Check for path traversal
|
|
340
|
+
if _has_path_traversal(member.name):
|
|
341
|
+
raise ValueError(f"Path traversal detected: {member.name}")
|
|
342
|
+
|
|
343
|
+
# Check directory depth
|
|
344
|
+
if _exceeds_depth_limit(member.name):
|
|
345
|
+
raise ValueError(f"Directory depth too deep: {member.name}")
|
|
346
|
+
|
|
347
|
+
# Check for blocked file extensions
|
|
348
|
+
if _is_blocked_extension(member.name):
|
|
349
|
+
raise ValueError(f"Blocked file type: {member.name}")
|
|
350
|
+
|
|
351
|
+
# Check for symlinks
|
|
352
|
+
if member.issym() or member.islnk():
|
|
353
|
+
raise ValueError(f"Symlinks not allowed: {member.name}")
|
|
354
|
+
|
|
355
|
+
# Check for device files, fifos, etc.
|
|
356
|
+
if not member.isfile() and not member.isdir():
|
|
357
|
+
raise ValueError(f"Unsupported file type: {member.name} (type: {member.type})")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _has_path_traversal(filename: str) -> bool:
|
|
361
|
+
"""Check if filename contains path traversal attempts."""
|
|
362
|
+
return ".." in filename
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _exceeds_depth_limit(filename: str) -> bool:
|
|
366
|
+
"""Check if filename exceeds directory depth limit."""
|
|
367
|
+
return filename.count("/") > MAX_DEPTH or filename.count("\\") > MAX_DEPTH
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_blocked_extension(filename: str) -> bool:
|
|
371
|
+
"""Check if filename has a blocked extension."""
|
|
372
|
+
file_ext = Path(filename).suffix.lower()
|
|
373
|
+
return file_ext in BLOCKED_EXTENSIONS
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_symlink(info) -> bool:
|
|
377
|
+
"""Check if ZIP file info indicates a symlink."""
|
|
378
|
+
return bool(info.external_attr & 0xA000 == 0xA000) # Symlink flag
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _get_safe_extraction_path(filename: str, extraction_dir: Path) -> Path:
|
|
382
|
+
"""Get a safe path for extraction that prevents directory traversal."""
|
|
383
|
+
# Reject paths with directory traversal attempts or absolute paths
|
|
384
|
+
if (
|
|
385
|
+
".." in filename
|
|
386
|
+
or filename.startswith("/")
|
|
387
|
+
or "\\" in filename
|
|
388
|
+
or ":" in filename
|
|
389
|
+
):
|
|
390
|
+
raise ValueError(f"Path traversal or absolute path detected: {filename}")
|
|
391
|
+
|
|
392
|
+
# Normalize path separators and remove leading/trailing slashes
|
|
393
|
+
normalized = filename.replace("\\", "/").strip("/")
|
|
394
|
+
|
|
395
|
+
# Split into components and filter out dangerous ones
|
|
396
|
+
parts: list[str] = []
|
|
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
|
|
415
|
+
|
|
416
|
+
return safe_path
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
420
|
+
"""Create a ZIP archive containing analysis results."""
|
|
421
|
+
zip_buffer = io.BytesIO()
|
|
422
|
+
|
|
423
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
424
|
+
# Add JSON summary
|
|
425
|
+
json_data = pd.DataFrame(results).to_json(indent=2)
|
|
426
|
+
zip_file.writestr("analysis_results.json", json_data)
|
|
427
|
+
|
|
428
|
+
# Add individual cookbook reports
|
|
429
|
+
for result in results:
|
|
430
|
+
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
431
|
+
report_content = f"""# Cookbook Analysis Report: {result["name"]}
|
|
432
|
+
|
|
433
|
+
## Metadata
|
|
434
|
+
- **Version**: {result["version"]}
|
|
435
|
+
- **Maintainer**: {result["maintainer"]}
|
|
436
|
+
- **Dependencies**: {result["dependencies"]}
|
|
437
|
+
- **Complexity**: {result["complexity"]}
|
|
438
|
+
- **Estimated Hours**: {result["estimated_hours"]:.1f}
|
|
439
|
+
|
|
440
|
+
## Recommendations
|
|
441
|
+
{result["recommendations"]}
|
|
442
|
+
|
|
443
|
+
## Source Path
|
|
444
|
+
{result["path"]}
|
|
445
|
+
"""
|
|
446
|
+
zip_file.writestr(f"{result['name']}_report.md", report_content)
|
|
447
|
+
|
|
448
|
+
# Add summary report
|
|
449
|
+
successful = len(
|
|
450
|
+
[r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
451
|
+
)
|
|
452
|
+
total_hours = sum(r.get("estimated_hours", 0) for r in results)
|
|
453
|
+
|
|
454
|
+
summary_content = f"""# SousChef Cookbook Analysis Summary
|
|
455
|
+
|
|
456
|
+
## Overview
|
|
457
|
+
- **Cookbooks Analysed**: {len(results)}
|
|
458
|
+
|
|
459
|
+
- **Successfully Analysed**: {successful}
|
|
460
|
+
|
|
461
|
+
- **Total Estimated Hours**: {total_hours:.1f}
|
|
462
|
+
- **Source**: {cookbook_path}
|
|
463
|
+
|
|
464
|
+
## Results Summary
|
|
465
|
+
"""
|
|
466
|
+
for result in results:
|
|
467
|
+
status_icon = "✅" if result["status"] == ANALYSIS_STATUS_ANALYSED else "❌"
|
|
468
|
+
summary_content += f"- {status_icon} {result['name']}: {result['status']}"
|
|
469
|
+
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
470
|
+
summary_content += (
|
|
471
|
+
f" ({result['estimated_hours']:.1f} hours, "
|
|
472
|
+
f"{result['complexity']} complexity)"
|
|
473
|
+
)
|
|
474
|
+
summary_content += "\n"
|
|
475
|
+
|
|
476
|
+
zip_file.writestr("analysis_summary.md", summary_content)
|
|
477
|
+
|
|
478
|
+
zip_buffer.seek(0)
|
|
479
|
+
return zip_buffer.getvalue()
|
|
480
|
+
|
|
22
481
|
|
|
23
482
|
def show_cookbook_analysis_page():
|
|
24
483
|
"""Show the cookbook analysis page."""
|
|
25
484
|
_setup_cookbook_analysis_ui()
|
|
26
|
-
cookbook_path = _get_cookbook_path_input()
|
|
27
485
|
|
|
28
|
-
|
|
29
|
-
|
|
486
|
+
# Initialise session state for analysis results
|
|
487
|
+
|
|
488
|
+
if "analysis_results" not in st.session_state:
|
|
489
|
+
st.session_state.analysis_results = None
|
|
490
|
+
st.session_state.analysis_cookbook_path = None
|
|
491
|
+
st.session_state.total_cookbooks = 0
|
|
492
|
+
st.session_state.temp_dir = None
|
|
30
493
|
|
|
31
|
-
|
|
494
|
+
# Check if we have analysis results to display
|
|
495
|
+
if st.session_state.analysis_results is not None:
|
|
496
|
+
_display_analysis_results(
|
|
497
|
+
st.session_state.analysis_results,
|
|
498
|
+
st.session_state.total_cookbooks,
|
|
499
|
+
)
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
# Check if we have an uploaded file from the dashboard
|
|
503
|
+
if "uploaded_file_data" in st.session_state:
|
|
504
|
+
_handle_dashboard_upload()
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# Input method selection
|
|
508
|
+
input_method = st.radio(
|
|
509
|
+
"Choose Input Method",
|
|
510
|
+
["Upload Archive", "Directory Path"],
|
|
511
|
+
horizontal=True,
|
|
512
|
+
help="Select how to provide cookbooks for analysis",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
cookbook_path = None
|
|
516
|
+
temp_dir = None
|
|
517
|
+
uploaded_file = None
|
|
518
|
+
|
|
519
|
+
if input_method == "Directory Path":
|
|
520
|
+
cookbook_path = _get_cookbook_path_input()
|
|
521
|
+
else:
|
|
522
|
+
uploaded_file = _get_archive_upload_input()
|
|
523
|
+
if uploaded_file:
|
|
524
|
+
try:
|
|
525
|
+
with st.spinner("Extracting archive..."):
|
|
526
|
+
temp_dir, cookbook_path = extract_archive(uploaded_file)
|
|
527
|
+
# Store temp_dir in session state to prevent premature cleanup
|
|
528
|
+
st.session_state.temp_dir = temp_dir
|
|
529
|
+
st.success("Archive extracted successfully to temporary location")
|
|
530
|
+
except Exception as e:
|
|
531
|
+
st.error(f"Failed to extract archive: {e}")
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
if cookbook_path:
|
|
536
|
+
_validate_and_list_cookbooks(cookbook_path)
|
|
537
|
+
|
|
538
|
+
_display_instructions()
|
|
539
|
+
finally:
|
|
540
|
+
# Only clean up temp_dir if it wasn't stored in session state
|
|
541
|
+
# (i.e., if we didn't successfully extract an archive)
|
|
542
|
+
if temp_dir and temp_dir.exists() and st.session_state.temp_dir != temp_dir:
|
|
543
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
32
544
|
|
|
33
545
|
|
|
34
546
|
def _setup_cookbook_analysis_ui():
|
|
35
547
|
"""Set up the cookbook analysis page header."""
|
|
36
|
-
st.
|
|
548
|
+
st.title("SousChef - Cookbook Analysis")
|
|
549
|
+
st.markdown("""
|
|
550
|
+
Analyse your Chef cookbooks and get detailed migration assessments for
|
|
551
|
+
converting to Ansible playbooks.
|
|
552
|
+
|
|
553
|
+
Upload a cookbook archive or specify a directory path to begin analysis.
|
|
554
|
+
""")
|
|
37
555
|
|
|
38
556
|
|
|
39
557
|
def _get_cookbook_path_input():
|
|
40
558
|
"""Get the cookbook path input from the user."""
|
|
41
559
|
return st.text_input(
|
|
42
560
|
"Cookbook Directory Path",
|
|
43
|
-
placeholder="/
|
|
44
|
-
help="Enter
|
|
561
|
+
placeholder="cookbooks/ or ../shared/cookbooks/",
|
|
562
|
+
help="Enter a path to your Chef cookbooks directory. "
|
|
563
|
+
"Relative paths (e.g., 'cookbooks/') and absolute paths inside the workspace "
|
|
564
|
+
"(e.g., '/workspaces/souschef/cookbooks/') are allowed.",
|
|
45
565
|
)
|
|
46
566
|
|
|
47
567
|
|
|
568
|
+
def _get_archive_upload_input():
|
|
569
|
+
"""Get archive upload input from the user."""
|
|
570
|
+
uploaded_file = st.file_uploader(
|
|
571
|
+
"Upload Cookbook Archive",
|
|
572
|
+
type=["zip", "tar.gz", "tgz", "tar"],
|
|
573
|
+
help="Upload a ZIP or TAR archive containing your Chef cookbooks",
|
|
574
|
+
)
|
|
575
|
+
return uploaded_file
|
|
576
|
+
|
|
577
|
+
|
|
48
578
|
def _validate_and_list_cookbooks(cookbook_path):
|
|
49
579
|
"""Validate the cookbook path and list available cookbooks."""
|
|
50
580
|
safe_dir = _get_safe_cookbook_directory(cookbook_path)
|
|
@@ -52,7 +582,6 @@ def _validate_and_list_cookbooks(cookbook_path):
|
|
|
52
582
|
return
|
|
53
583
|
|
|
54
584
|
if safe_dir.exists() and safe_dir.is_dir():
|
|
55
|
-
st.success(f"Found directory: {safe_dir}")
|
|
56
585
|
_list_and_display_cookbooks(safe_dir)
|
|
57
586
|
else:
|
|
58
587
|
st.error(f"Directory not found: {safe_dir}")
|
|
@@ -62,29 +591,61 @@ def _get_safe_cookbook_directory(cookbook_path):
|
|
|
62
591
|
"""
|
|
63
592
|
Resolve the user-provided cookbook path to a safe directory.
|
|
64
593
|
|
|
65
|
-
The path is
|
|
66
|
-
|
|
594
|
+
The path is validated and normalized to prevent directory traversal
|
|
595
|
+
outside the allowed root before any path operations.
|
|
67
596
|
"""
|
|
68
597
|
try:
|
|
69
598
|
base_dir = Path.cwd().resolve()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
599
|
+
temp_dir = Path(tempfile.gettempdir()).resolve()
|
|
600
|
+
|
|
601
|
+
path_str = str(cookbook_path).strip()
|
|
602
|
+
|
|
603
|
+
# Reject obviously malicious patterns
|
|
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
|
+
)
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
user_path = Path(path_str)
|
|
620
|
+
|
|
621
|
+
# Resolve the path safely
|
|
622
|
+
if user_path.is_absolute():
|
|
623
|
+
resolved_path = user_path.resolve()
|
|
73
624
|
else:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
625
|
+
resolved_path = (base_dir / user_path).resolve()
|
|
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
|
|
633
|
+
|
|
634
|
+
try:
|
|
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
|
|
78
644
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
candidate.relative_to(base_dir)
|
|
82
|
-
except ValueError:
|
|
83
|
-
st.error("The specified path is outside the allowed cookbook directory root.")
|
|
645
|
+
except Exception as exc:
|
|
646
|
+
st.error(f"❌ Invalid path: {exc}. Please enter a valid relative path.")
|
|
84
647
|
return None
|
|
85
648
|
|
|
86
|
-
return candidate
|
|
87
|
-
|
|
88
649
|
|
|
89
650
|
def _list_and_display_cookbooks(cookbook_path: Path):
|
|
90
651
|
"""List cookbooks in the directory and display them."""
|
|
@@ -108,14 +669,14 @@ def _collect_cookbook_data(cookbooks):
|
|
|
108
669
|
"""Collect data for all cookbooks."""
|
|
109
670
|
cookbook_data = []
|
|
110
671
|
for cookbook in cookbooks:
|
|
111
|
-
cookbook_info =
|
|
672
|
+
cookbook_info = _analyse_cookbook_metadata(cookbook)
|
|
112
673
|
cookbook_data.append(cookbook_info)
|
|
113
674
|
return cookbook_data
|
|
114
675
|
|
|
115
676
|
|
|
116
|
-
def
|
|
117
|
-
"""
|
|
118
|
-
metadata_file = cookbook /
|
|
677
|
+
def _analyse_cookbook_metadata(cookbook):
|
|
678
|
+
"""Analyse metadata for a single cookbook."""
|
|
679
|
+
metadata_file = cookbook / METADATA_FILENAME
|
|
119
680
|
if metadata_file.exists():
|
|
120
681
|
return _parse_metadata_with_fallback(cookbook, metadata_file)
|
|
121
682
|
else:
|
|
@@ -150,7 +711,7 @@ def _extract_cookbook_info(metadata, cookbook, metadata_status):
|
|
|
150
711
|
}
|
|
151
712
|
|
|
152
713
|
|
|
153
|
-
def _normalize_description(description):
|
|
714
|
+
def _normalize_description(description: Any) -> str:
|
|
154
715
|
"""
|
|
155
716
|
Normalize description to string format.
|
|
156
717
|
|
|
@@ -202,36 +763,124 @@ def _display_cookbook_table(cookbook_data):
|
|
|
202
763
|
st.dataframe(df, use_container_width=True)
|
|
203
764
|
|
|
204
765
|
|
|
205
|
-
def _handle_cookbook_selection(cookbook_path, cookbook_data):
|
|
206
|
-
"""Handle
|
|
207
|
-
|
|
208
|
-
str(cb["Name"])
|
|
209
|
-
for cb in cookbook_data
|
|
210
|
-
if cb[METADATA_COLUMN_NAME] == METADATA_STATUS_YES
|
|
211
|
-
]
|
|
766
|
+
def _handle_cookbook_selection(cookbook_path: str, cookbook_data: list):
|
|
767
|
+
"""Handle selection of cookbooks for analysis."""
|
|
768
|
+
st.subheader("Select Cookbooks to Analyse")
|
|
212
769
|
|
|
770
|
+
# Create a multiselect widget for cookbook selection
|
|
771
|
+
cookbook_names = [cookbook["Name"] for cookbook in cookbook_data]
|
|
213
772
|
selected_cookbooks = st.multiselect(
|
|
214
|
-
"
|
|
215
|
-
|
|
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
|
+
)
|
|
778
|
+
|
|
779
|
+
# Show selection summary
|
|
780
|
+
if selected_cookbooks:
|
|
781
|
+
st.info(f"Selected {len(selected_cookbooks)} cookbook(s) for analysis")
|
|
782
|
+
|
|
783
|
+
# Analyse button
|
|
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")
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _handle_dashboard_upload():
|
|
791
|
+
"""Handle file uploaded from the dashboard."""
|
|
792
|
+
# Create a file-like object from the stored data
|
|
793
|
+
file_data = st.session_state.uploaded_file_data
|
|
794
|
+
file_name = st.session_state.uploaded_file_name
|
|
795
|
+
|
|
796
|
+
# Create a file-like object that mimics the UploadedFile interface
|
|
797
|
+
class MockUploadedFile:
|
|
798
|
+
def __init__(self, data, name, mime_type):
|
|
799
|
+
self.data = data
|
|
800
|
+
self.name = name
|
|
801
|
+
self.type = mime_type
|
|
802
|
+
|
|
803
|
+
def getbuffer(self):
|
|
804
|
+
return self.data
|
|
805
|
+
|
|
806
|
+
def getvalue(self):
|
|
807
|
+
return self.data
|
|
808
|
+
|
|
809
|
+
mock_file = MockUploadedFile(
|
|
810
|
+
file_data, file_name, st.session_state.uploaded_file_type
|
|
216
811
|
)
|
|
217
812
|
|
|
218
|
-
|
|
219
|
-
|
|
813
|
+
# Display upload info
|
|
814
|
+
st.info(f"📁 Using file uploaded from Dashboard: {file_name}")
|
|
815
|
+
|
|
816
|
+
# Add option to clear and upload a different file
|
|
817
|
+
col1, col2 = st.columns([1, 1])
|
|
818
|
+
with col1:
|
|
819
|
+
if st.button(
|
|
820
|
+
"Use Different File", help="Clear this file and upload a different one"
|
|
821
|
+
):
|
|
822
|
+
# Clear the uploaded file from session state
|
|
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()
|
|
827
|
+
|
|
828
|
+
with col2:
|
|
829
|
+
if st.button("Back to Dashboard", help="Return to dashboard"):
|
|
830
|
+
st.session_state.current_page = "Dashboard"
|
|
831
|
+
st.rerun()
|
|
832
|
+
|
|
833
|
+
# Process the file
|
|
834
|
+
try:
|
|
835
|
+
with st.spinner("Extracting archive..."):
|
|
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!")
|
|
840
|
+
|
|
841
|
+
# Validate and list cookbooks
|
|
842
|
+
if cookbook_path:
|
|
843
|
+
_validate_and_list_cookbooks(cookbook_path)
|
|
844
|
+
|
|
845
|
+
except Exception as e:
|
|
846
|
+
st.error(f"Failed to process uploaded file: {e}")
|
|
847
|
+
# Clear the uploaded file on error
|
|
848
|
+
if "uploaded_file_data" in st.session_state:
|
|
849
|
+
del st.session_state.uploaded_file_data
|
|
850
|
+
del st.session_state.uploaded_file_name
|
|
851
|
+
del st.session_state.uploaded_file_type
|
|
220
852
|
|
|
221
853
|
|
|
222
854
|
def _display_instructions():
|
|
223
855
|
"""Display usage instructions."""
|
|
224
856
|
with st.expander("How to Use"):
|
|
225
857
|
st.markdown("""
|
|
226
|
-
|
|
227
|
-
|
|
858
|
+
## Input Methods
|
|
859
|
+
|
|
860
|
+
### Directory Path
|
|
861
|
+
1. **Enter Cookbook Path**: Provide a **relative path** to your cookbooks
|
|
862
|
+
(absolute paths not allowed)
|
|
228
863
|
2. **Review Cookbooks**: The interface will list all cookbooks with metadata
|
|
229
|
-
3. **Select Cookbooks**: Choose which cookbooks to
|
|
230
|
-
4. **Run Analysis**: Click "
|
|
864
|
+
3. **Select Cookbooks**: Choose which cookbooks to analyse
|
|
865
|
+
4. **Run Analysis**: Click "Analyse Selected Cookbooks" to get detailed insights
|
|
866
|
+
|
|
867
|
+
**Path Examples:**
|
|
868
|
+
- `cookbooks/` - subdirectory in current workspace
|
|
869
|
+
- `../shared/cookbooks/` - parent directory
|
|
870
|
+
- `./my-cookbooks/` - explicit current directory
|
|
871
|
+
|
|
872
|
+
### Archive Upload
|
|
873
|
+
1. **Upload Archive**: Upload a ZIP or TAR archive containing your cookbooks
|
|
874
|
+
2. **Automatic Extraction**: The system will extract and analyse the archive
|
|
231
875
|
|
|
232
|
-
**
|
|
876
|
+
3. **Review Cookbooks**: Interface will list all cookbooks found in archive
|
|
877
|
+
4. **Select Cookbooks**: Choose which cookbooks to analyse
|
|
878
|
+
5. **Run Analysis**: Click "Analyse Selected Cookbooks" to get insights
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
## Expected Structure
|
|
233
882
|
```
|
|
234
|
-
/
|
|
883
|
+
cookbooks/ or archive.zip/
|
|
235
884
|
├── nginx/
|
|
236
885
|
│ ├── metadata.rb
|
|
237
886
|
│ ├── recipes/
|
|
@@ -241,11 +890,16 @@ def _display_instructions():
|
|
|
241
890
|
└── mysql/
|
|
242
891
|
└── metadata.rb
|
|
243
892
|
```
|
|
893
|
+
|
|
894
|
+
## Supported Archive Formats
|
|
895
|
+
- ZIP (.zip)
|
|
896
|
+
- TAR (.tar)
|
|
897
|
+
- GZIP-compressed TAR (.tar.gz, .tgz)
|
|
244
898
|
""")
|
|
245
899
|
|
|
246
900
|
|
|
247
|
-
def
|
|
248
|
-
"""
|
|
901
|
+
def analyse_selected_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
|
|
902
|
+
"""Analyse the selected cookbooks and store results in session state."""
|
|
249
903
|
st.subheader("Analysis Results")
|
|
250
904
|
|
|
251
905
|
progress_bar, status_text = _setup_analysis_progress()
|
|
@@ -254,7 +908,14 @@ def analyze_selected_cookbooks(cookbook_path: str, selected_cookbooks: list[str]
|
|
|
254
908
|
)
|
|
255
909
|
|
|
256
910
|
_cleanup_progress_indicators(progress_bar, status_text)
|
|
257
|
-
|
|
911
|
+
|
|
912
|
+
# Store results in session state
|
|
913
|
+
st.session_state.analysis_results = results
|
|
914
|
+
st.session_state.analysis_cookbook_path = cookbook_path
|
|
915
|
+
st.session_state.total_cookbooks = len(selected_cookbooks)
|
|
916
|
+
|
|
917
|
+
# Trigger rerun to display results
|
|
918
|
+
st.rerun()
|
|
258
919
|
|
|
259
920
|
|
|
260
921
|
def _setup_analysis_progress():
|
|
@@ -277,7 +938,7 @@ def _perform_cookbook_analysis(
|
|
|
277
938
|
|
|
278
939
|
cookbook_dir = _find_cookbook_directory(cookbook_path, cookbook_name)
|
|
279
940
|
if cookbook_dir:
|
|
280
|
-
analysis_result =
|
|
941
|
+
analysis_result = _analyse_single_cookbook(cookbook_name, cookbook_dir)
|
|
281
942
|
results.append(analysis_result)
|
|
282
943
|
|
|
283
944
|
return results
|
|
@@ -296,11 +957,11 @@ def _find_cookbook_directory(cookbook_path, cookbook_name):
|
|
|
296
957
|
return None
|
|
297
958
|
|
|
298
959
|
|
|
299
|
-
def
|
|
300
|
-
"""
|
|
960
|
+
def _analyse_single_cookbook(cookbook_name, cookbook_dir):
|
|
961
|
+
"""Analyse a single cookbook."""
|
|
301
962
|
try:
|
|
302
963
|
assessment = parse_chef_migration_assessment(str(cookbook_dir))
|
|
303
|
-
metadata = parse_cookbook_metadata(str(cookbook_dir /
|
|
964
|
+
metadata = parse_cookbook_metadata(str(cookbook_dir / METADATA_FILENAME))
|
|
304
965
|
|
|
305
966
|
return _create_successful_analysis(
|
|
306
967
|
cookbook_name, cookbook_dir, assessment, metadata
|
|
@@ -321,7 +982,7 @@ def _create_successful_analysis(cookbook_name, cookbook_dir, assessment, metadat
|
|
|
321
982
|
"complexity": assessment.get("complexity", "Unknown"),
|
|
322
983
|
"estimated_hours": assessment.get("estimated_hours", 0),
|
|
323
984
|
"recommendations": assessment.get("recommendations", ""),
|
|
324
|
-
"status":
|
|
985
|
+
"status": ANALYSIS_STATUS_ANALYSED,
|
|
325
986
|
}
|
|
326
987
|
|
|
327
988
|
|
|
@@ -348,12 +1009,79 @@ def _cleanup_progress_indicators(progress_bar, status_text):
|
|
|
348
1009
|
|
|
349
1010
|
|
|
350
1011
|
def _display_analysis_results(results, total_cookbooks):
|
|
351
|
-
"""Display the analysis results."""
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
1012
|
+
"""Display the complete analysis results."""
|
|
1013
|
+
# Add a back button to return to analysis selection
|
|
1014
|
+
col1, col2 = st.columns([1, 4])
|
|
1015
|
+
with col1:
|
|
1016
|
+
if st.button("⬅️ Analyse More Cookbooks", help="Return to cookbook selection"):
|
|
1017
|
+
# Clear session state to go back to selection
|
|
1018
|
+
st.session_state.analysis_results = None
|
|
1019
|
+
st.session_state.analysis_cookbook_path = None
|
|
1020
|
+
st.session_state.total_cookbooks = 0
|
|
1021
|
+
# Clean up temporary directory when going back
|
|
1022
|
+
if st.session_state.temp_dir and st.session_state.temp_dir.exists():
|
|
1023
|
+
shutil.rmtree(st.session_state.temp_dir, ignore_errors=True)
|
|
1024
|
+
st.session_state.temp_dir = None
|
|
1025
|
+
st.rerun()
|
|
1026
|
+
|
|
1027
|
+
with col2:
|
|
1028
|
+
st.subheader("Analysis Results")
|
|
1029
|
+
|
|
1030
|
+
_display_analysis_summary(results, total_cookbooks)
|
|
1031
|
+
_display_results_table(results)
|
|
1032
|
+
_display_detailed_analysis(results)
|
|
1033
|
+
_display_download_option(results)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _display_download_option(results):
|
|
1037
|
+
"""Display download options for analysis results."""
|
|
1038
|
+
st.subheader("Download Options")
|
|
1039
|
+
|
|
1040
|
+
successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
1041
|
+
|
|
1042
|
+
if not successful_results:
|
|
1043
|
+
st.info("No successfully analysed cookbooks available for download.")
|
|
1044
|
+
|
|
1045
|
+
return
|
|
1046
|
+
|
|
1047
|
+
col1, _col2 = st.columns(2)
|
|
1048
|
+
|
|
1049
|
+
with col1:
|
|
1050
|
+
# Download analysis report
|
|
1051
|
+
analysis_data = _create_analysis_report(results)
|
|
1052
|
+
st.download_button(
|
|
1053
|
+
label="Download Analysis Report",
|
|
1054
|
+
data=analysis_data,
|
|
1055
|
+
file_name="cookbook_analysis_report.json",
|
|
1056
|
+
mime="application/json",
|
|
1057
|
+
help="Download detailed analysis results as JSON",
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
# Convert to Ansible Playbooks button - moved outside columns for better reliability
|
|
1061
|
+
if st.button(
|
|
1062
|
+
"Convert to Ansible Playbooks",
|
|
1063
|
+
type="primary",
|
|
1064
|
+
help="Convert analysed cookbooks to Ansible playbooks and download as ZIP",
|
|
1065
|
+
):
|
|
1066
|
+
# Check AI configuration status
|
|
1067
|
+
ai_config = load_ai_settings()
|
|
1068
|
+
ai_available = (
|
|
1069
|
+
ai_config.get("provider")
|
|
1070
|
+
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
1071
|
+
and ai_config.get("api_key")
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
if ai_available:
|
|
1075
|
+
provider = ai_config.get("provider", "Unknown")
|
|
1076
|
+
model = ai_config.get("model", "Unknown")
|
|
1077
|
+
st.info(f"🤖 Using AI-enhanced conversion with {provider} ({model})")
|
|
1078
|
+
else:
|
|
1079
|
+
st.info(
|
|
1080
|
+
"⚙️ Using deterministic conversion. Configure AI settings "
|
|
1081
|
+
"for enhanced results."
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
_convert_and_download_playbooks(results)
|
|
357
1085
|
|
|
358
1086
|
|
|
359
1087
|
def _display_analysis_summary(results, total_cookbooks):
|
|
@@ -362,9 +1090,9 @@ def _display_analysis_summary(results, total_cookbooks):
|
|
|
362
1090
|
|
|
363
1091
|
with col1:
|
|
364
1092
|
successful = len(
|
|
365
|
-
[r for r in results if r["status"] ==
|
|
1093
|
+
[r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
366
1094
|
)
|
|
367
|
-
st.metric("Successfully
|
|
1095
|
+
st.metric("Successfully Analysed", f"{successful}/{total_cookbooks}")
|
|
368
1096
|
|
|
369
1097
|
with col2:
|
|
370
1098
|
total_hours = sum(r.get("estimated_hours", 0) for r in results)
|
|
@@ -379,7 +1107,7 @@ def _display_analysis_summary(results, total_cookbooks):
|
|
|
379
1107
|
def _display_results_table(results):
|
|
380
1108
|
"""Display results in a table format."""
|
|
381
1109
|
df = pd.DataFrame(results)
|
|
382
|
-
st.dataframe(df,
|
|
1110
|
+
st.dataframe(df, width="stretch")
|
|
383
1111
|
|
|
384
1112
|
|
|
385
1113
|
def _display_detailed_analysis(results):
|
|
@@ -387,7 +1115,7 @@ def _display_detailed_analysis(results):
|
|
|
387
1115
|
st.subheader("Detailed Analysis")
|
|
388
1116
|
|
|
389
1117
|
for result in results:
|
|
390
|
-
if result["status"] ==
|
|
1118
|
+
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
391
1119
|
_display_single_cookbook_details(result)
|
|
392
1120
|
|
|
393
1121
|
|
|
@@ -408,18 +1136,225 @@ def _display_single_cookbook_details(result):
|
|
|
408
1136
|
st.write(f"**Recommendations:** {result['recommendations']}")
|
|
409
1137
|
|
|
410
1138
|
|
|
411
|
-
def
|
|
412
|
-
"""
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1139
|
+
def _convert_and_download_playbooks(results):
|
|
1140
|
+
"""Convert analysed cookbooks to Ansible playbooks and provide download."""
|
|
1141
|
+
successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
1142
|
+
|
|
1143
|
+
if not successful_results:
|
|
1144
|
+
st.warning("No successfully analysed cookbooks to convert.")
|
|
1145
|
+
return
|
|
1146
|
+
|
|
1147
|
+
with st.spinner("Converting cookbooks to Ansible playbooks..."):
|
|
1148
|
+
playbooks = []
|
|
1149
|
+
|
|
1150
|
+
for result in successful_results:
|
|
1151
|
+
playbook_data = _convert_single_cookbook(result)
|
|
1152
|
+
if playbook_data:
|
|
1153
|
+
playbooks.append(playbook_data)
|
|
1154
|
+
|
|
1155
|
+
if playbooks:
|
|
1156
|
+
# Save converted playbooks to temporary directory for validation
|
|
1157
|
+
try:
|
|
1158
|
+
output_dir = Path(tempfile.mkdtemp(prefix="souschef_converted_"))
|
|
1159
|
+
for playbook in playbooks:
|
|
1160
|
+
# Sanitize filename
|
|
1161
|
+
filename = f"{playbook['cookbook_name']}.yml"
|
|
1162
|
+
(output_dir / filename).write_text(playbook["playbook_content"])
|
|
1163
|
+
|
|
1164
|
+
# Store path in session state for validation page
|
|
1165
|
+
st.session_state.converted_playbooks_path = str(output_dir)
|
|
1166
|
+
st.success("Playbooks converted and staged for validation.")
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
st.warning(f"Could not stage playbooks for validation: {e}")
|
|
1169
|
+
|
|
1170
|
+
_handle_playbook_download(playbooks)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _convert_single_cookbook(result):
|
|
1174
|
+
"""Convert a single cookbook to Ansible playbook."""
|
|
1175
|
+
cookbook_dir = Path(result["path"])
|
|
1176
|
+
recipe_file = _find_recipe_file(cookbook_dir, result["name"])
|
|
1177
|
+
|
|
1178
|
+
if not recipe_file:
|
|
1179
|
+
return None
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
# Check if AI-enhanced conversion is available and enabled
|
|
1183
|
+
ai_config = load_ai_settings()
|
|
1184
|
+
use_ai = (
|
|
1185
|
+
ai_config.get("provider")
|
|
1186
|
+
and ai_config.get("provider") != LOCAL_PROVIDER
|
|
1187
|
+
and ai_config.get("api_key")
|
|
421
1188
|
)
|
|
422
1189
|
|
|
1190
|
+
if use_ai:
|
|
1191
|
+
# Use AI-enhanced conversion
|
|
1192
|
+
# Map provider display names to API provider strings
|
|
1193
|
+
provider_mapping = {
|
|
1194
|
+
"Anthropic Claude": "anthropic",
|
|
1195
|
+
"Anthropic (Claude)": "anthropic",
|
|
1196
|
+
"OpenAI": "openai",
|
|
1197
|
+
"OpenAI (GPT)": "openai",
|
|
1198
|
+
"IBM Watsonx": "watson",
|
|
1199
|
+
"Red Hat Lightspeed": "lightspeed",
|
|
1200
|
+
}
|
|
1201
|
+
provider_name = ai_config.get("provider", "")
|
|
1202
|
+
ai_provider = provider_mapping.get(
|
|
1203
|
+
provider_name, provider_name.lower().replace(" ", "_")
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
playbook_content = generate_playbook_from_recipe_with_ai(
|
|
1207
|
+
str(recipe_file),
|
|
1208
|
+
ai_provider=ai_provider,
|
|
1209
|
+
api_key=ai_config.get("api_key", ""),
|
|
1210
|
+
model=ai_config.get("model", "claude-3-5-sonnet-20241022"),
|
|
1211
|
+
temperature=ai_config.get("temperature", 0.7),
|
|
1212
|
+
max_tokens=ai_config.get("max_tokens", 4000),
|
|
1213
|
+
project_id=ai_config.get("project_id", ""),
|
|
1214
|
+
base_url=ai_config.get("base_url", ""),
|
|
1215
|
+
)
|
|
1216
|
+
else:
|
|
1217
|
+
# Use deterministic conversion
|
|
1218
|
+
playbook_content = generate_playbook_from_recipe(str(recipe_file))
|
|
1219
|
+
|
|
1220
|
+
if not playbook_content.startswith("Error"):
|
|
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
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _find_recipe_file(cookbook_dir, cookbook_name):
|
|
1236
|
+
"""Find the appropriate recipe file for a cookbook."""
|
|
1237
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
1238
|
+
if not recipes_dir.exists():
|
|
1239
|
+
st.warning(f"No recipes directory found in {cookbook_name}")
|
|
1240
|
+
return None
|
|
1241
|
+
|
|
1242
|
+
recipe_files = list(recipes_dir.glob("*.rb"))
|
|
1243
|
+
if not recipe_files:
|
|
1244
|
+
st.warning(f"No recipe files found in {cookbook_name}")
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
# Use the default.rb recipe if available, otherwise first recipe
|
|
1248
|
+
default_recipe = recipes_dir / "default.rb"
|
|
1249
|
+
return default_recipe if default_recipe.exists() else recipe_files[0]
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _handle_playbook_download(playbooks):
|
|
1253
|
+
"""Handle the download of generated playbooks."""
|
|
1254
|
+
if not playbooks:
|
|
1255
|
+
st.error("No playbooks were successfully generated.")
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
# Create ZIP archive with all playbooks
|
|
1259
|
+
playbook_archive = _create_playbook_archive(playbooks)
|
|
1260
|
+
|
|
1261
|
+
st.success(
|
|
1262
|
+
f"Successfully converted {len(playbooks)} cookbooks to Ansible playbooks!"
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
# Provide download button
|
|
1266
|
+
st.download_button(
|
|
1267
|
+
label="Download Ansible Playbooks",
|
|
1268
|
+
data=playbook_archive,
|
|
1269
|
+
file_name="ansible_playbooks.zip",
|
|
1270
|
+
mime="application/zip",
|
|
1271
|
+
help="Download ZIP archive containing all generated Ansible playbooks",
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
# Show preview of generated playbooks
|
|
1275
|
+
with st.expander("Preview Generated Playbooks"):
|
|
1276
|
+
for playbook in playbooks:
|
|
1277
|
+
conversion_badge = (
|
|
1278
|
+
"🤖 AI-Enhanced"
|
|
1279
|
+
if playbook.get("conversion_method") == "AI-enhanced"
|
|
1280
|
+
else "⚙️ Deterministic"
|
|
1281
|
+
)
|
|
1282
|
+
st.subheader(
|
|
1283
|
+
f"{playbook['cookbook_name']} ({conversion_badge}) - "
|
|
1284
|
+
f"from {playbook['recipe_file']}"
|
|
1285
|
+
)
|
|
1286
|
+
st.code(
|
|
1287
|
+
playbook["playbook_content"][:1000] + "..."
|
|
1288
|
+
if len(playbook["playbook_content"]) > 1000
|
|
1289
|
+
else playbook["playbook_content"],
|
|
1290
|
+
language="yaml",
|
|
1291
|
+
)
|
|
1292
|
+
st.divider()
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _create_playbook_archive(playbooks):
|
|
1296
|
+
"""Create a ZIP archive containing all generated Ansible playbooks."""
|
|
1297
|
+
zip_buffer = io.BytesIO()
|
|
1298
|
+
|
|
1299
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
1300
|
+
# Add individual playbook files
|
|
1301
|
+
for playbook in playbooks:
|
|
1302
|
+
playbook_filename = f"{playbook['cookbook_name']}.yml"
|
|
1303
|
+
zip_file.writestr(playbook_filename, playbook["playbook_content"])
|
|
1304
|
+
|
|
1305
|
+
# Add a summary README
|
|
1306
|
+
readme_content = f"""# Ansible Playbooks Generated by SousChef
|
|
1307
|
+
|
|
1308
|
+
This archive contains {len(playbooks)} Ansible playbooks converted from Chef cookbooks.
|
|
1309
|
+
|
|
1310
|
+
## Contents:
|
|
1311
|
+
"""
|
|
1312
|
+
|
|
1313
|
+
for playbook in playbooks:
|
|
1314
|
+
conversion_method = playbook.get("conversion_method", "Deterministic")
|
|
1315
|
+
readme_content += (
|
|
1316
|
+
f"- {playbook['cookbook_name']}.yml "
|
|
1317
|
+
f"(converted from {playbook['recipe_file']}, "
|
|
1318
|
+
f"method: {conversion_method})\n"
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
readme_content += """
|
|
1322
|
+
|
|
1323
|
+
## Usage:
|
|
1324
|
+
Run these playbooks with Ansible:
|
|
1325
|
+
ansible-playbook <playbook_name>.yml
|
|
1326
|
+
|
|
1327
|
+
## Notes:
|
|
1328
|
+
- These playbooks were automatically generated from Chef recipes
|
|
1329
|
+
- Review and test the playbooks before using in production
|
|
1330
|
+
- Some manual adjustments may be required for complex recipes
|
|
1331
|
+
"""
|
|
1332
|
+
|
|
1333
|
+
zip_file.writestr("README.md", readme_content)
|
|
1334
|
+
|
|
1335
|
+
zip_buffer.seek(0)
|
|
1336
|
+
return zip_buffer.getvalue()
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _create_analysis_report(results):
|
|
1340
|
+
"""Create a JSON report of the analysis results."""
|
|
1341
|
+
report = {
|
|
1342
|
+
"analysis_summary": {
|
|
1343
|
+
"total_cookbooks": len(results),
|
|
1344
|
+
"successful_analyses": len(
|
|
1345
|
+
[r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
1346
|
+
),
|
|
1347
|
+
"total_estimated_hours": sum(r.get("estimated_hours", 0) for r in results),
|
|
1348
|
+
"high_complexity_count": len(
|
|
1349
|
+
[r for r in results if r.get("complexity") == "High"]
|
|
1350
|
+
),
|
|
1351
|
+
},
|
|
1352
|
+
"cookbook_details": results,
|
|
1353
|
+
"generated_at": str(pd.Timestamp.now()),
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return json.dumps(report, indent=2)
|
|
1357
|
+
|
|
423
1358
|
|
|
424
1359
|
if __name__ == "__main__":
|
|
425
1360
|
show_cookbook_analysis_page()
|