mcp-souschef 2.5.3__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,16 @@
1
1
  """Cookbook Analysis Page for SousChef UI."""
2
2
 
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import shutil
3
8
  import sys
9
+ import tarfile
10
+ import tempfile
11
+ import zipfile
4
12
  from pathlib import Path
13
+ from typing import Any
5
14
 
6
15
  import pandas as pd # type: ignore[import-untyped]
7
16
  import streamlit as st
@@ -9,50 +18,827 @@ import streamlit as st
9
18
  # Add the parent directory to the path so we can import souschef modules
10
19
  sys.path.insert(0, str(Path(__file__).parent.parent.parent))
11
20
 
12
- from souschef.assessment import parse_chef_migration_assessment
21
+ from souschef.assessment import (
22
+ analyse_cookbook_dependencies,
23
+ assess_single_cookbook_with_ai,
24
+ )
25
+ from souschef.converters.playbook import (
26
+ generate_playbook_from_recipe,
27
+ generate_playbook_from_recipe_with_ai,
28
+ )
29
+ from souschef.converters.template import convert_cookbook_templates
30
+ from souschef.core.constants import METADATA_FILENAME
31
+ from souschef.core.metrics import (
32
+ EffortMetrics,
33
+ get_timeline_weeks,
34
+ validate_metrics_consistency,
35
+ )
13
36
  from souschef.parsers.metadata import parse_cookbook_metadata
14
37
 
38
+ # AI Settings
39
+ ANTHROPIC_PROVIDER = "Anthropic (Claude)"
40
+ ANTHROPIC_CLAUDE_DISPLAY = "Anthropic Claude"
41
+ OPENAI_PROVIDER = "OpenAI (GPT)"
42
+ LOCAL_PROVIDER = "Local Model"
43
+ IBM_WATSONX = "IBM Watsonx"
44
+ RED_HAT_LIGHTSPEED = "Red Hat Lightspeed"
45
+
46
+
47
+ def _sanitize_filename(filename: str) -> str:
48
+ """
49
+ Sanitise filename to prevent path injection attacks.
50
+
51
+ Args:
52
+ filename: The filename to sanitise.
53
+
54
+ Returns:
55
+ Sanitised filename safe for file operations.
56
+
57
+ """
58
+ import re
59
+
60
+ # Remove any path separators and parent directory references
61
+ sanitised = filename.replace("..", "_").replace("/", "_").replace("\\", "_")
62
+ # Remove any null bytes or control characters
63
+ sanitised = re.sub(r"[\x00-\x1f\x7f]", "_", sanitised)
64
+ # Remove leading/trailing whitespace and dots
65
+ sanitised = sanitised.strip(". ")
66
+ # Limit length to prevent issues
67
+ sanitised = sanitised[:255]
68
+ return sanitised if sanitised else "unnamed"
69
+
70
+
71
+ def _get_secure_ai_config_path() -> Path:
72
+ """Return a private, non-world-writable path for AI config storage."""
73
+ config_dir = Path(tempfile.gettempdir()) / ".souschef"
74
+ config_dir.mkdir(mode=0o700, exist_ok=True)
75
+ with contextlib.suppress(OSError):
76
+ config_dir.chmod(0o700)
77
+
78
+ if config_dir.is_symlink():
79
+ raise ValueError("AI config directory cannot be a symlink")
80
+
81
+ return config_dir / "ai_config.json"
82
+
83
+
84
+ def load_ai_settings() -> dict[str, str | float | int]:
85
+ """Load AI settings from environment variables or configuration file."""
86
+ # First try to load from environment variables
87
+ env_config = _load_ai_settings_from_env()
88
+
89
+ # If we have environment config, use it
90
+ if env_config:
91
+ return env_config
92
+
93
+ # Fall back to loading from configuration file
94
+ return _load_ai_settings_from_file()
95
+
96
+
97
+ def _load_ai_settings_from_env() -> dict[str, str | float | int]:
98
+ """Load AI settings from environment variables."""
99
+ import os
100
+ from contextlib import suppress
101
+
102
+ env_config: dict[str, str | float | int] = {}
103
+ env_mappings = {
104
+ "SOUSCHEF_AI_PROVIDER": "provider",
105
+ "SOUSCHEF_AI_MODEL": "model",
106
+ "SOUSCHEF_AI_API_KEY": "api_key",
107
+ "SOUSCHEF_AI_BASE_URL": "base_url",
108
+ "SOUSCHEF_AI_PROJECT_ID": "project_id",
109
+ }
110
+
111
+ # Handle string values
112
+ for env_var, config_key in env_mappings.items():
113
+ env_value = os.environ.get(env_var)
114
+ if env_value:
115
+ env_config[config_key] = env_value
116
+
117
+ # Handle numeric values with error suppression
118
+ temp_value = os.environ.get("SOUSCHEF_AI_TEMPERATURE")
119
+ if temp_value:
120
+ with suppress(ValueError):
121
+ env_config["temperature"] = float(temp_value)
122
+
123
+ tokens_value = os.environ.get("SOUSCHEF_AI_MAX_TOKENS")
124
+ if tokens_value:
125
+ with suppress(ValueError):
126
+ env_config["max_tokens"] = int(tokens_value)
127
+
128
+ return env_config
129
+
130
+
131
+ def _load_ai_settings_from_file() -> dict[str, str | float | int]:
132
+ """Load AI settings from configuration file."""
133
+ try:
134
+ config_file = _get_secure_ai_config_path()
135
+ if config_file.exists():
136
+ with config_file.open() as f:
137
+ file_config = json.load(f)
138
+ return dict(file_config) if isinstance(file_config, dict) else {}
139
+ except (ValueError, OSError):
140
+ return {}
141
+ return {}
142
+
143
+
144
+ def _get_ai_provider(ai_config: dict[str, str | float | int]) -> str:
145
+ """
146
+ Safely get the AI provider from config with proper type handling.
147
+
148
+ Args:
149
+ ai_config: The AI configuration dictionary.
150
+
151
+ Returns:
152
+ The AI provider string, or empty string if not found.
153
+
154
+ """
155
+ provider_raw = ai_config.get("provider", "")
156
+ if isinstance(provider_raw, str):
157
+ return provider_raw
158
+ return str(provider_raw) if provider_raw else ""
159
+
160
+
161
+ def _get_ai_string_value(
162
+ ai_config: dict[str, str | float | int], key: str, default: str = ""
163
+ ) -> str:
164
+ """
165
+ Safely get a string value from AI config.
166
+
167
+ Args:
168
+ ai_config: The AI configuration dictionary.
169
+ key: The key to retrieve.
170
+ default: Default value if key not found.
171
+
172
+ Returns:
173
+ The string value or default.
174
+
175
+ """
176
+ value = ai_config.get(key, default)
177
+ if isinstance(value, str):
178
+ return value
179
+ return str(value) if value else default
180
+
181
+
182
+ def _get_ai_float_value(
183
+ ai_config: dict[str, str | float | int], key: str, default: float = 0.7
184
+ ) -> float:
185
+ """
186
+ Safely get a float value from AI config.
187
+
188
+ Args:
189
+ ai_config: The AI configuration dictionary.
190
+ key: The key to retrieve.
191
+ default: Default value if key not found or conversion fails.
192
+
193
+ Returns:
194
+ The float value or default.
195
+
196
+ """
197
+ value = ai_config.get(key)
198
+ if isinstance(value, float):
199
+ return value
200
+ elif isinstance(value, int):
201
+ return float(value)
202
+ elif isinstance(value, str):
203
+ try:
204
+ return float(value)
205
+ except ValueError:
206
+ return default
207
+ return default
208
+
209
+
210
+ def _get_ai_int_value(
211
+ ai_config: dict[str, str | float | int], key: str, default: int = 4000
212
+ ) -> int:
213
+ """
214
+ Safely get an int value from AI config.
215
+
216
+ Args:
217
+ ai_config: The AI configuration dictionary.
218
+ key: The key to retrieve.
219
+ default: Default value if key not found or conversion fails.
220
+
221
+ Returns:
222
+ The int value or default.
223
+
224
+ """
225
+ value = ai_config.get(key)
226
+ if isinstance(value, int):
227
+ return value
228
+ elif isinstance(value, float):
229
+ return int(value)
230
+ elif isinstance(value, str):
231
+ try:
232
+ return int(value)
233
+ except ValueError:
234
+ return default
235
+ return default
236
+
237
+
15
238
  # Constants for repeated strings
16
239
  METADATA_STATUS_YES = "Yes"
17
240
  METADATA_STATUS_NO = "No"
18
- ANALYSIS_STATUS_ANALYZED = "Analyzed"
241
+ ANALYSIS_STATUS_ANALYSED = "Analysed"
19
242
  ANALYSIS_STATUS_FAILED = "Failed"
20
243
  METADATA_COLUMN_NAME = "Has Metadata"
21
244
 
245
+ # Security limits for archive extraction
246
+ MAX_ARCHIVE_SIZE = 100 * 1024 * 1024 # 100MB total
247
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB per file
248
+ MAX_FILES = 1000 # Maximum number of files
249
+ MAX_DEPTH = 10 # Maximum directory depth
250
+ BLOCKED_EXTENSIONS = {
251
+ ".exe",
252
+ ".bat",
253
+ ".cmd",
254
+ ".com",
255
+ ".pif",
256
+ ".scr",
257
+ ".vbs",
258
+ ".js",
259
+ ".jar",
260
+ # Note: .sh files are allowed as they are common in Chef cookbooks
261
+ }
262
+
263
+
264
+ def extract_archive(uploaded_file) -> tuple[Path, Path]:
265
+ """
266
+ Extract uploaded archive to a temporary directory with security checks.
267
+
268
+ Returns:
269
+ tuple: (temp_dir_path, cookbook_root_path)
270
+
271
+ Implements multiple security measures to prevent:
272
+ - Zip bombs (size limits, file count limits)
273
+ - Path traversal attacks (../ validation)
274
+ - Resource exhaustion (depth limits, size limits)
275
+ - Malicious files (symlinks, executables blocked)
276
+
277
+ """
278
+ # Check initial file size
279
+ file_size = len(uploaded_file.getbuffer())
280
+ if file_size > MAX_ARCHIVE_SIZE:
281
+ raise ValueError(
282
+ f"Archive too large: {file_size} bytes (max: {MAX_ARCHIVE_SIZE})"
283
+ )
284
+
285
+ # Create temporary directory with secure permissions (owner-only access)
286
+ temp_dir = Path(tempfile.mkdtemp())
287
+ with contextlib.suppress(FileNotFoundError, OSError):
288
+ temp_dir.chmod(0o700) # Secure permissions: rwx------
289
+ temp_path = temp_dir
290
+
291
+ # Save uploaded file
292
+ archive_path = temp_path / uploaded_file.name
293
+ with archive_path.open("wb") as f:
294
+ f.write(uploaded_file.getbuffer())
295
+
296
+ # Extract archive with security checks
297
+ extraction_dir = temp_path / "extracted"
298
+ extraction_dir.mkdir()
299
+
300
+ _extract_archive_by_type(archive_path, extraction_dir, uploaded_file.name)
301
+
302
+ # Find the root directory (should contain cookbooks)
303
+ cookbook_root = _determine_cookbook_root(extraction_dir)
304
+
305
+ return temp_dir, cookbook_root
306
+
307
+
308
+ def _extract_archive_by_type(
309
+ archive_path: Path, extraction_dir: Path, filename: str
310
+ ) -> None:
311
+ """Extract archive based on file extension."""
312
+ if filename.endswith(".zip"):
313
+ _extract_zip_securely(archive_path, extraction_dir)
314
+ elif filename.endswith((".tar.gz", ".tgz")):
315
+ _extract_tar_securely(archive_path, extraction_dir, gzipped=True)
316
+ elif filename.endswith(".tar"):
317
+ _extract_tar_securely(archive_path, extraction_dir, gzipped=False)
318
+ else:
319
+ raise ValueError(f"Unsupported archive format: {filename}")
320
+
321
+
322
+ def _determine_cookbook_root(extraction_dir: Path) -> Path:
323
+ """Determine the root directory containing cookbooks."""
324
+ subdirs = [d for d in extraction_dir.iterdir() if d.is_dir()]
325
+
326
+ # Check if this looks like a single cookbook archive (contains typical
327
+ # cookbook dirs)
328
+ cookbook_dirs = {
329
+ "recipes",
330
+ "attributes",
331
+ "templates",
332
+ "files",
333
+ "libraries",
334
+ "definitions",
335
+ }
336
+ extracted_dirs = {d.name for d in subdirs}
337
+
338
+ cookbook_root = extraction_dir
339
+
340
+ if len(subdirs) > 1 and cookbook_dirs.intersection(extracted_dirs):
341
+ # Case 1: Multiple cookbook directories at root level
342
+ cookbook_root = _handle_multiple_cookbook_dirs(extraction_dir, subdirs)
343
+ elif len(subdirs) == 1:
344
+ # Case 2: Single directory - check if it contains cookbook components
345
+ cookbook_root = _handle_single_cookbook_dir(
346
+ extraction_dir, subdirs[0], cookbook_dirs
347
+ )
348
+ # else: Multiple directories that are not cookbook components - use extraction_dir
349
+
350
+ return cookbook_root
351
+
352
+
353
+ def _handle_multiple_cookbook_dirs(extraction_dir: Path, subdirs: list) -> Path:
354
+ """Handle case where multiple cookbook directories are at root level."""
355
+ synthetic_cookbook_dir = extraction_dir / "cookbook"
356
+ synthetic_cookbook_dir.mkdir(exist_ok=True)
357
+
358
+ # Move all extracted directories into the synthetic cookbook
359
+ for subdir in subdirs:
360
+ if subdir.name in {
361
+ "recipes",
362
+ "attributes",
363
+ "templates",
364
+ "files",
365
+ "libraries",
366
+ "definitions",
367
+ }:
368
+ shutil.move(str(subdir), str(synthetic_cookbook_dir / subdir.name))
369
+
370
+ # Create a basic metadata.rb file
371
+ metadata_content = """name 'extracted_cookbook'
372
+ maintainer 'SousChef'
373
+ maintainer_email 'souschef@example.com'
374
+ license 'All rights reserved'
375
+ description 'Automatically extracted cookbook from archive'
376
+ version '1.0.0'
377
+ """
378
+ (synthetic_cookbook_dir / METADATA_FILENAME).write_text(metadata_content)
379
+
380
+ return extraction_dir
381
+
382
+
383
+ def _handle_single_cookbook_dir(
384
+ extraction_dir: Path, single_dir: Path, cookbook_dirs: set
385
+ ) -> Path:
386
+ """Handle case where single directory contains cookbook components."""
387
+ single_dir_contents = {d.name for d in single_dir.iterdir() if d.is_dir()}
388
+
389
+ if cookbook_dirs.intersection(single_dir_contents):
390
+ # This single directory contains cookbook components - treat it as a cookbook
391
+ # Check if it already has metadata.rb
392
+ if not (single_dir / METADATA_FILENAME).exists():
393
+ # Create synthetic metadata.rb
394
+ metadata_content = f"""name '{single_dir.name}'
395
+ maintainer 'SousChef'
396
+ maintainer_email 'souschef@example.com'
397
+ license 'All rights reserved'
398
+ description 'Automatically extracted cookbook from archive'
399
+ version '1.0.0'
400
+ """
401
+ (single_dir / METADATA_FILENAME).write_text(metadata_content)
402
+
403
+ # Return the parent directory so it will scan and find the cookbook inside
404
+ return extraction_dir
405
+ else:
406
+ # Single directory that doesn't contain cookbook components
407
+ # It might be a wrapper directory containing multiple cookbooks
408
+ return single_dir
409
+
410
+
411
+ def _extract_zip_securely(archive_path: Path, extraction_dir: Path) -> None:
412
+ """Extract ZIP archive with security checks."""
413
+ total_size = 0
414
+
415
+ with zipfile.ZipFile(archive_path, "r") as zip_ref:
416
+ # Pre-scan for security issues
417
+ for file_count, info in enumerate(zip_ref.filelist, start=1):
418
+ _validate_zip_file_security(info, file_count, total_size)
419
+ total_size += info.file_size
420
+
421
+ # Safe extraction with manual path handling
422
+ for info in zip_ref.filelist:
423
+ # Construct safe relative path
424
+ safe_path = _get_safe_extraction_path(info.filename, extraction_dir)
425
+
426
+ if info.is_dir():
427
+ # Create directory
428
+ safe_path.mkdir(parents=True, exist_ok=True)
429
+ else:
430
+ # Create parent directories if needed
431
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
432
+ # Extract file content manually
433
+ with zip_ref.open(info) as source, safe_path.open("wb") as target:
434
+ # Read in chunks to control memory usage
435
+ while True:
436
+ chunk = source.read(8192)
437
+ if not chunk:
438
+ break
439
+ target.write(chunk)
440
+
441
+
442
+ def _validate_zip_file_security(info, file_count: int, total_size: int) -> None:
443
+ """Validate a single ZIP file entry for security issues."""
444
+ file_count += 1
445
+ if file_count > MAX_FILES:
446
+ raise ValueError(f"Too many files in archive: {file_count} (max: {MAX_FILES})")
447
+
448
+ # Check file size
449
+ if info.file_size > MAX_FILE_SIZE:
450
+ raise ValueError(f"File too large: {info.filename} ({info.file_size} bytes)")
451
+
452
+ total_size += info.file_size
453
+ if total_size > MAX_ARCHIVE_SIZE:
454
+ raise ValueError(f"Total archive size too large: {total_size} bytes")
455
+
456
+ # Check for path traversal
457
+ if _has_path_traversal(info.filename):
458
+ raise ValueError(f"Path traversal detected: {info.filename}")
459
+
460
+ # Check directory depth
461
+ if _exceeds_depth_limit(info.filename):
462
+ raise ValueError(f"Directory depth too deep: {info.filename}")
463
+
464
+ # Check for blocked file extensions
465
+ if _is_blocked_extension(info.filename):
466
+ raise ValueError(f"Blocked file type: {info.filename}")
467
+
468
+ # Check for symlinks
469
+ if _is_symlink(info):
470
+ raise ValueError(f"Symlinks not allowed: {info.filename}")
471
+
472
+
473
+ def _extract_tar_securely(
474
+ archive_path: Path, extraction_dir: Path, gzipped: bool
475
+ ) -> None:
476
+ """Extract TAR archive with security checks."""
477
+ mode = "r:gz" if gzipped else "r"
478
+
479
+ if not archive_path.is_file():
480
+ raise ValueError(f"Archive path is not a file: {archive_path}")
481
+
482
+ if not tarfile.is_tarfile(str(archive_path)):
483
+ raise ValueError(f"Invalid or corrupted TAR archive: {archive_path.name}")
484
+
485
+ try:
486
+ with tarfile.open( # type: ignore[call-overload] # NOSONAR
487
+ str(archive_path), mode=mode, filter="data"
488
+ ) as tar_ref:
489
+ members = tar_ref.getmembers()
490
+ _pre_scan_tar_members(members)
491
+ _extract_tar_members(tar_ref, members, extraction_dir)
492
+ except tarfile.TarError as e:
493
+ raise ValueError(f"Invalid or corrupted TAR archive: {e}") from e
494
+ except Exception as e:
495
+ raise ValueError(f"Failed to process TAR archive: {e}") from e
496
+
497
+
498
+ def _pre_scan_tar_members(members):
499
+ """Pre-scan TAR members for security issues and accumulate totals."""
500
+ total_size = 0
501
+ for file_count, member in enumerate(members, start=1):
502
+ total_size += member.size
503
+ _validate_tar_file_security(member, file_count, total_size)
504
+
505
+
506
+ def _extract_tar_members(tar_ref, members, extraction_dir):
507
+ """Extract validated TAR members to the extraction directory."""
508
+ for member in members:
509
+ safe_path = _get_safe_extraction_path(member.name, extraction_dir)
510
+ if member.isdir():
511
+ safe_path.mkdir(parents=True, exist_ok=True)
512
+ else:
513
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
514
+ _extract_file_content(tar_ref, member, safe_path)
515
+
516
+
517
+ def _extract_file_content(tar_ref, member, safe_path):
518
+ """Extract the content of a single TAR member to a file."""
519
+ source = tar_ref.extractfile(member)
520
+ if source:
521
+ with source, safe_path.open("wb") as target:
522
+ while True:
523
+ chunk = source.read(8192)
524
+ if not chunk:
525
+ break
526
+ target.write(chunk)
527
+
528
+
529
+ def _validate_tar_file_security(member, file_count: int, total_size: int) -> None:
530
+ """Validate a single TAR file entry for security issues."""
531
+ file_count += 1
532
+ if file_count > MAX_FILES:
533
+ raise ValueError(f"Too many files in archive: {file_count} (max: {MAX_FILES})")
534
+
535
+ # Check file size
536
+ if member.size > MAX_FILE_SIZE:
537
+ raise ValueError(f"File too large: {member.name} ({member.size} bytes)")
538
+
539
+ total_size += member.size
540
+ if total_size > MAX_ARCHIVE_SIZE:
541
+ raise ValueError(f"Total archive size too large: {total_size} bytes")
542
+
543
+ # Check for path traversal
544
+ if _has_path_traversal(member.name):
545
+ raise ValueError(f"Path traversal detected: {member.name}")
546
+
547
+ # Check directory depth
548
+ if _exceeds_depth_limit(member.name):
549
+ raise ValueError(f"Directory depth too deep: {member.name}")
550
+
551
+ # Check for blocked file extensions
552
+ if _is_blocked_extension(member.name):
553
+ raise ValueError(f"Blocked file type: {member.name}")
554
+
555
+ # Check for symlinks
556
+ if member.issym() or member.islnk():
557
+ raise ValueError(f"Symlinks not allowed: {member.name}")
558
+
559
+ # Check for device files, fifos, etc.
560
+ if not member.isfile() and not member.isdir():
561
+ raise ValueError(f"Unsupported file type: {member.name} (type: {member.type})")
562
+
563
+
564
+ def _has_path_traversal(filename: str) -> bool:
565
+ """Check if filename contains path traversal attempts."""
566
+ return ".." in filename
567
+
568
+
569
+ def _exceeds_depth_limit(filename: str) -> bool:
570
+ """Check if filename exceeds directory depth limit."""
571
+ return filename.count("/") > MAX_DEPTH or filename.count("\\") > MAX_DEPTH
572
+
573
+
574
+ def _is_blocked_extension(filename: str) -> bool:
575
+ """Check if filename has a blocked extension."""
576
+ file_ext = Path(filename).suffix.lower()
577
+ return file_ext in BLOCKED_EXTENSIONS
578
+
579
+
580
+ def _is_symlink(info) -> bool:
581
+ """Check if ZIP file info indicates a symlink."""
582
+ return bool(info.external_attr & 0xA000 == 0xA000) # Symlink flag
583
+
584
+
585
+ def _get_safe_extraction_path(filename: str, extraction_dir: Path) -> Path:
586
+ """Get a safe path for extraction that prevents directory traversal."""
587
+ # Reject paths with directory traversal attempts or absolute paths
588
+ if (
589
+ ".." in filename
590
+ or filename.startswith("/")
591
+ or "\\" in filename
592
+ or ":" in filename
593
+ ):
594
+ raise ValueError(f"Path traversal or absolute path detected: {filename}")
595
+
596
+ # Normalize path separators and remove leading/trailing slashes
597
+ normalized = filename.replace("\\", "/").strip("/")
598
+
599
+ # Split into components and filter out dangerous ones
600
+ parts: list[str] = []
601
+ for part in normalized.split("/"):
602
+ if part == "" or part == ".":
603
+ continue
604
+ elif part == "..":
605
+ # Remove parent directory if we have one
606
+ if parts:
607
+ parts.pop()
608
+ else:
609
+ parts.append(part)
610
+
611
+ # Join parts back and resolve against extraction_dir
612
+ safe_path = extraction_dir / "/".join(parts)
613
+
614
+ # Ensure the final path is still within extraction_dir
615
+ try:
616
+ safe_path.resolve().relative_to(extraction_dir.resolve())
617
+ except ValueError:
618
+ raise ValueError(f"Path traversal detected: {filename}") from None
619
+
620
+ return safe_path
621
+
622
+
623
+ def create_results_archive(results: list, cookbook_path: str) -> bytes:
624
+ """Create a ZIP archive containing analysis results."""
625
+ zip_buffer = io.BytesIO()
626
+
627
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
628
+ # Add JSON summary
629
+ json_data = pd.DataFrame(results).to_json(indent=2)
630
+ zip_file.writestr("analysis_results.json", json_data)
631
+
632
+ # Add individual cookbook reports
633
+ for result in results:
634
+ if result["status"] == ANALYSIS_STATUS_ANALYSED:
635
+ report_content = f"""# Cookbook Analysis Report: {result["name"]}
636
+
637
+ ## Metadata
638
+ - **Version**: {result["version"]}
639
+ - **Maintainer**: {result["maintainer"]}
640
+ - **Dependencies**: {result["dependencies"]}
641
+ - **Complexity**: {result["complexity"]}
642
+ - **Estimated Hours**: {result["estimated_hours"]:.1f}
643
+
644
+ ## Recommendations
645
+ {result["recommendations"]}
646
+
647
+ ## Source Path
648
+ {cookbook_path} # deepcode ignore PT: used for display only, not file operations
649
+ """
650
+ zip_file.writestr(f"{result['name']}_report.md", report_content)
651
+
652
+ # Add summary report
653
+ successful = len(
654
+ [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
655
+ )
656
+ total_hours = sum(r.get("estimated_hours", 0) for r in results)
657
+
658
+ summary_content = f"""# SousChef Cookbook Analysis Summary
659
+
660
+ ## Overview
661
+ - **Cookbooks Analysed**: {len(results)}
662
+
663
+ - **Successfully Analysed**: {successful}
664
+
665
+ - **Total Estimated Hours**: {total_hours:.1f}
666
+ - **Source**: {cookbook_path} # deepcode ignore PT: used for display only
667
+
668
+ ## Results Summary
669
+ """
670
+ for result in results:
671
+ status_icon = (
672
+ "PASS" if result["status"] == ANALYSIS_STATUS_ANALYSED else "FAIL"
673
+ )
674
+ summary_content += f"- {status_icon} {result['name']}: {result['status']}"
675
+ if result["status"] == ANALYSIS_STATUS_ANALYSED:
676
+ summary_content += (
677
+ f" ({result['estimated_hours']:.1f} hours, "
678
+ f"{result['complexity']} complexity)"
679
+ )
680
+ summary_content += "\n"
681
+
682
+ zip_file.writestr("analysis_summary.md", summary_content)
683
+
684
+ zip_buffer.seek(0)
685
+ return zip_buffer.getvalue()
686
+
22
687
 
23
- def show_cookbook_analysis_page():
688
+ def show_cookbook_analysis_page() -> None:
24
689
  """Show the cookbook analysis page."""
690
+ # Initialise session state for analysis results
691
+ if "analysis_results" not in st.session_state:
692
+ st.session_state.analysis_results = None
693
+ st.session_state.analysis_cookbook_path = None
694
+ st.session_state.total_cookbooks = 0
695
+ st.session_state.temp_dir = None
696
+
697
+ # Add unique key to track if this is a new page load
698
+ if "analysis_page_key" not in st.session_state:
699
+ st.session_state.analysis_page_key = 0
700
+
25
701
  _setup_cookbook_analysis_ui()
26
- cookbook_path = _get_cookbook_path_input()
27
702
 
28
- if cookbook_path:
29
- _validate_and_list_cookbooks(cookbook_path)
703
+ # Check if we have analysis results to display
704
+ if st.session_state.analysis_results is not None:
705
+ _display_results_view()
706
+ return
707
+
708
+ # Check if we have an uploaded file from the dashboard
709
+ if "uploaded_file_data" in st.session_state:
710
+ _handle_dashboard_upload()
711
+ return
712
+
713
+ _show_analysis_input()
714
+
715
+
716
+ def _show_analysis_input() -> None:
717
+ """Show analysis input interface."""
718
+ # Input method selection
719
+ input_method = st.radio(
720
+ "Choose Input Method",
721
+ ["Upload Archive", "Directory Path"],
722
+ horizontal=True,
723
+ help="Select how to provide cookbooks for analysis",
724
+ )
725
+
726
+ cookbook_path: str | Path | None = None
727
+ temp_dir = None
728
+ uploaded_file = None
729
+
730
+ if input_method == "Directory Path":
731
+ cookbook_path = _get_cookbook_path_input()
732
+ else:
733
+ uploaded_file = _get_archive_upload_input()
734
+ if uploaded_file:
735
+ try:
736
+ with st.spinner("Extracting archive..."):
737
+ # Clear any previous analysis results
738
+ st.session_state.analysis_results = None
739
+ st.session_state.holistic_assessment = None
740
+
741
+ temp_dir, cookbook_path = extract_archive(uploaded_file)
742
+ # Store temp_dir in session state to prevent premature cleanup
743
+ st.session_state.temp_dir = temp_dir
744
+ st.success("Archive extracted successfully to temporary location")
745
+ except Exception as e:
746
+ st.error(f"Failed to extract archive: {e}")
747
+ return
748
+
749
+ try:
750
+ if cookbook_path:
751
+ _validate_and_list_cookbooks(str(cookbook_path))
752
+
753
+ _display_instructions()
754
+ finally:
755
+ # Only clean up temp_dir if it wasn't stored in session state
756
+ # (i.e., if we didn't successfully extract an archive)
757
+ if temp_dir and temp_dir.exists() and st.session_state.temp_dir != temp_dir:
758
+ shutil.rmtree(temp_dir, ignore_errors=True)
759
+
30
760
 
31
- _display_instructions()
761
+ def _display_results_view() -> None:
762
+ """Display the results view with new analysis button."""
763
+ # Add a "New Analysis" button at the top of results page
764
+ col1, col2 = st.columns([6, 1])
765
+ with col1:
766
+ st.write("") # Spacer
767
+ with col2:
768
+ if st.button(
769
+ "New Analysis",
770
+ help="Start a new analysis",
771
+ key=f"new_analysis_{st.session_state.analysis_page_key}",
772
+ ):
773
+ st.session_state.analysis_results = None
774
+ st.session_state.holistic_assessment = None
775
+ st.session_state.analysis_cookbook_path = None
776
+ st.session_state.total_cookbooks = None
777
+ st.session_state.analysis_info_messages = None
778
+ st.session_state.analysis_page_key += 1
779
+ st.rerun()
780
+
781
+ _display_analysis_results(
782
+ st.session_state.analysis_results,
783
+ st.session_state.total_cookbooks,
784
+ )
32
785
 
33
786
 
34
- def _setup_cookbook_analysis_ui():
787
+ def _setup_cookbook_analysis_ui() -> None:
35
788
  """Set up the cookbook analysis page header."""
36
- st.header("Cookbook Analysis")
789
+ st.title("SousChef - Cookbook Analysis")
790
+ st.markdown("""
791
+ Analyse your Chef cookbooks and get detailed migration assessments for
792
+ converting to Ansible playbooks.
37
793
 
794
+ Upload a cookbook archive or specify a directory path to begin analysis.
795
+ """)
38
796
 
39
- def _get_cookbook_path_input():
797
+ # Add back to dashboard button
798
+ col1, _ = st.columns([1, 4])
799
+ with col1:
800
+ if st.button(
801
+ "← Back to Dashboard",
802
+ help="Return to main dashboard",
803
+ key="back_to_dashboard_from_analysis",
804
+ ):
805
+ # Clear all analysis state
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.current_page = "Dashboard"
811
+ st.rerun()
812
+
813
+
814
+ def _get_cookbook_path_input() -> str:
40
815
  """Get the cookbook path input from the user."""
41
816
  return st.text_input(
42
817
  "Cookbook Directory Path",
43
- placeholder="/path/to/your/cookbooks",
44
- help="Enter the absolute path to your Chef cookbooks directory",
818
+ placeholder="cookbooks/ or ../shared/cookbooks/",
819
+ help="Enter a path to your Chef cookbooks directory. "
820
+ "Relative paths (e.g., 'cookbooks/') and absolute paths inside the workspace "
821
+ "(e.g., '/workspaces/souschef/cookbooks/') are allowed.",
45
822
  )
46
823
 
47
824
 
48
- def _validate_and_list_cookbooks(cookbook_path):
825
+ def _get_archive_upload_input() -> Any:
826
+ """Get archive upload input from the user."""
827
+ uploaded_file = st.file_uploader(
828
+ "Upload Cookbook Archive",
829
+ type=["zip", "tar.gz", "tgz", "tar"],
830
+ help="Upload a ZIP or TAR archive containing your Chef cookbooks",
831
+ )
832
+ return uploaded_file
833
+
834
+
835
+ def _validate_and_list_cookbooks(cookbook_path: str) -> None:
49
836
  """Validate the cookbook path and list available cookbooks."""
50
837
  safe_dir = _get_safe_cookbook_directory(cookbook_path)
51
838
  if safe_dir is None:
52
839
  return
53
840
 
54
841
  if safe_dir.exists() and safe_dir.is_dir():
55
- st.success(f"Found directory: {safe_dir}")
56
842
  _list_and_display_cookbooks(safe_dir)
57
843
  else:
58
844
  st.error(f"Directory not found: {safe_dir}")
@@ -62,29 +848,61 @@ def _get_safe_cookbook_directory(cookbook_path):
62
848
  """
63
849
  Resolve the user-provided cookbook path to a safe directory.
64
850
 
65
- The path is resolved against a base directory and normalized to
66
- prevent directory traversal outside the allowed root.
851
+ The path is validated and normalized to prevent directory traversal
852
+ outside the allowed root before any path operations.
67
853
  """
68
854
  try:
69
855
  base_dir = Path.cwd().resolve()
70
- user_path = Path(cookbook_path.strip())
71
- if not user_path.is_absolute():
72
- candidate = (base_dir / user_path).resolve()
856
+ temp_dir = Path(tempfile.gettempdir()).resolve()
857
+
858
+ path_str = str(cookbook_path).strip()
859
+
860
+ # Reject obviously malicious patterns
861
+ if "\x00" in path_str or ":\\" in path_str or "\\" in path_str:
862
+ st.error(
863
+ "Invalid path: Path contains null bytes or backslashes, "
864
+ "which are not allowed."
865
+ )
866
+ return None
867
+
868
+ # Reject paths with directory traversal attempts
869
+ if ".." in path_str:
870
+ st.error(
871
+ "Invalid path: Path contains '..' which is not allowed "
872
+ "for security reasons."
873
+ )
874
+ return None
875
+
876
+ user_path = Path(path_str)
877
+
878
+ # Resolve the path safely
879
+ if user_path.is_absolute():
880
+ resolved_path = user_path.resolve()
73
881
  else:
74
- candidate = user_path.resolve()
75
- except Exception as exc:
76
- st.error(f"Invalid path: {exc}")
77
- return None
882
+ resolved_path = (base_dir / user_path).resolve()
883
+
884
+ # Check if the resolved path is within allowed directories
885
+ try:
886
+ resolved_path.relative_to(base_dir)
887
+ return resolved_path
888
+ except ValueError:
889
+ pass
890
+
891
+ try:
892
+ resolved_path.relative_to(temp_dir)
893
+ return resolved_path
894
+ except ValueError:
895
+ st.error(
896
+ "Invalid path: The resolved path is outside the allowed "
897
+ "directories (workspace or temporary directory). Paths cannot go above "
898
+ "the workspace root for security reasons."
899
+ )
900
+ return None
78
901
 
79
- # Ensure the final path is within the allowed base directory.
80
- try:
81
- candidate.relative_to(base_dir)
82
- except ValueError:
83
- st.error("The specified path is outside the allowed cookbook directory root.")
902
+ except Exception as exc:
903
+ st.error(f"Invalid path: {exc}. Please enter a valid relative path.")
84
904
  return None
85
905
 
86
- return candidate
87
-
88
906
 
89
907
  def _list_and_display_cookbooks(cookbook_path: Path):
90
908
  """List cookbooks in the directory and display them."""
@@ -108,14 +926,14 @@ def _collect_cookbook_data(cookbooks):
108
926
  """Collect data for all cookbooks."""
109
927
  cookbook_data = []
110
928
  for cookbook in cookbooks:
111
- cookbook_info = _analyze_cookbook_metadata(cookbook)
929
+ cookbook_info = _analyse_cookbook_metadata(cookbook)
112
930
  cookbook_data.append(cookbook_info)
113
931
  return cookbook_data
114
932
 
115
933
 
116
- def _analyze_cookbook_metadata(cookbook):
117
- """Analyze metadata for a single cookbook."""
118
- metadata_file = cookbook / "metadata.rb"
934
+ def _analyse_cookbook_metadata(cookbook):
935
+ """Analyse metadata for a single cookbook."""
936
+ metadata_file = cookbook / METADATA_FILENAME
119
937
  if metadata_file.exists():
120
938
  return _parse_metadata_with_fallback(cookbook, metadata_file)
121
939
  else:
@@ -150,7 +968,7 @@ def _extract_cookbook_info(metadata, cookbook, metadata_status):
150
968
  }
151
969
 
152
970
 
153
- def _normalize_description(description):
971
+ def _normalize_description(description: Any) -> str:
154
972
  """
155
973
  Normalize description to string format.
156
974
 
@@ -199,144 +1017,1283 @@ def _create_no_metadata_entry(cookbook):
199
1017
  def _display_cookbook_table(cookbook_data):
200
1018
  """Display the cookbook data in a table."""
201
1019
  df = pd.DataFrame(cookbook_data)
202
- st.dataframe(df, use_container_width=True)
1020
+ st.dataframe(df, width="stretch")
203
1021
 
204
1022
 
205
- def _handle_cookbook_selection(cookbook_path, cookbook_data):
206
- """Handle cookbook selection and analysis trigger."""
207
- available_cookbooks = [
208
- str(cb["Name"])
209
- for cb in cookbook_data
210
- if cb[METADATA_COLUMN_NAME] == METADATA_STATUS_YES
211
- ]
1023
+ def _handle_cookbook_selection(cookbook_path: str, cookbook_data: list):
1024
+ """Handle the cookbook selection interface with individual and holistic options."""
1025
+ st.subheader("Cookbook Selection & Analysis")
212
1026
 
213
- selected_cookbooks = st.multiselect(
214
- "Select cookbooks to analyze",
215
- available_cookbooks,
1027
+ # Show validation warnings if any cookbooks have issues
1028
+ _show_cookbook_validation_warnings(cookbook_data)
1029
+
1030
+ # Holistic analysis/conversion buttons
1031
+ st.markdown("### Holistic Analysis & Conversion")
1032
+ st.markdown(
1033
+ "Analyse and convert **ALL cookbooks** in the archive holistically, "
1034
+ "considering dependencies between cookbooks."
216
1035
  )
217
1036
 
218
- if selected_cookbooks and st.button("Analyze Selected Cookbooks", type="primary"):
219
- analyze_selected_cookbooks(cookbook_path, selected_cookbooks)
1037
+ col1, col2 = st.columns(2)
220
1038
 
1039
+ with col1:
1040
+ if st.button(
1041
+ "🔍 Analyse ALL Cookbooks",
1042
+ type="primary",
1043
+ help="Analyse all cookbooks together considering inter-cookbook "
1044
+ "dependencies",
1045
+ key="holistic_analysis",
1046
+ ):
1047
+ _analyze_all_cookbooks_holistically(cookbook_path, cookbook_data)
221
1048
 
222
- def _display_instructions():
223
- """Display usage instructions."""
224
- with st.expander("How to Use"):
225
- st.markdown("""
226
- 1. **Enter Cookbook Path**: Provide the absolute path to your cookbooks
227
- directory
228
- 2. **Review Cookbooks**: The interface will list all cookbooks with metadata
229
- 3. **Select Cookbooks**: Choose which cookbooks to analyze
230
- 4. **Run Analysis**: Click "Analyze Selected Cookbooks" to get detailed insights
1049
+ with col2:
1050
+ if st.button(
1051
+ "🔄 Convert ALL Cookbooks",
1052
+ type="secondary",
1053
+ help="Convert all cookbooks to Ansible roles considering dependencies",
1054
+ key="holistic_conversion",
1055
+ ):
1056
+ _convert_all_cookbooks_holistically(cookbook_path)
231
1057
 
232
- **Expected Structure:**
233
- ```
234
- /path/to/cookbooks/
235
- ├── nginx/
236
- │ ├── metadata.rb
237
- │ ├── recipes/
238
- │ └── attributes/
239
- ├── apache2/
240
- │ └── metadata.rb
241
- └── mysql/
242
- └── metadata.rb
243
- ```
244
- """)
1058
+ st.divider()
245
1059
 
1060
+ # Individual cookbook selection
1061
+ st.markdown("### Individual Cookbook Selection")
1062
+ st.markdown("Select specific cookbooks to analyse individually.")
246
1063
 
247
- def analyze_selected_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
248
- """Analyze the selected cookbooks and display results."""
249
- st.subheader("Analysis Results")
1064
+ # Get list of cookbook names for multiselect
1065
+ cookbook_names = [cb["Name"] for cb in cookbook_data]
250
1066
 
251
- progress_bar, status_text = _setup_analysis_progress()
252
- results = _perform_cookbook_analysis(
253
- cookbook_path, selected_cookbooks, progress_bar, status_text
1067
+ selected_cookbooks = st.multiselect(
1068
+ "Select cookbooks to analyse:",
1069
+ options=cookbook_names,
1070
+ default=[],
1071
+ help="Choose which cookbooks to analyse individually",
254
1072
  )
255
1073
 
256
- _cleanup_progress_indicators(progress_bar, status_text)
257
- _display_analysis_results(results, len(selected_cookbooks))
1074
+ if selected_cookbooks:
1075
+ col1, col2, col3 = st.columns(3)
258
1076
 
1077
+ with col1:
1078
+ if st.button(
1079
+ f"📊 Analyse Selected ({len(selected_cookbooks)})",
1080
+ help=f"Analyse {len(selected_cookbooks)} selected cookbooks",
1081
+ key="analyze_selected",
1082
+ ):
1083
+ analyse_selected_cookbooks(cookbook_path, selected_cookbooks)
259
1084
 
260
- def _setup_analysis_progress():
261
- """Set up progress tracking for analysis."""
262
- progress_bar = st.progress(0)
263
- status_text = st.empty()
264
- return progress_bar, status_text
1085
+ with col2:
1086
+ if st.button(
1087
+ f"🔗 Analyse as Project ({len(selected_cookbooks)})",
1088
+ help=f"Analyse {len(selected_cookbooks)} cookbooks as a project "
1089
+ f"with dependency analysis",
1090
+ key="analyze_project",
1091
+ ):
1092
+ analyse_project_cookbooks(cookbook_path, selected_cookbooks)
1093
+
1094
+ with col3:
1095
+ if st.button(
1096
+ f"📋 Select All ({len(cookbook_names)})",
1097
+ help=f"Select all {len(cookbook_names)} cookbooks",
1098
+ key="select_all",
1099
+ ):
1100
+ # This will trigger a rerun with all cookbooks selected
1101
+ st.session_state.selected_cookbooks = cookbook_names
1102
+ st.rerun()
1103
+
1104
+
1105
+ def _show_cookbook_validation_warnings(cookbook_data: list):
1106
+ """Show validation warnings for cookbooks that might not be analyzable."""
1107
+ problematic_cookbooks = []
1108
+
1109
+ for cookbook in cookbook_data:
1110
+ if cookbook.get(METADATA_COLUMN_NAME) == METADATA_STATUS_NO:
1111
+ problematic_cookbooks.append(cookbook["Name"])
1112
+
1113
+ if problematic_cookbooks:
1114
+ st.warning("Some cookbooks may not be analyzable:")
1115
+ st.markdown("**Cookbooks without valid metadata.rb:**")
1116
+ for name in problematic_cookbooks:
1117
+ st.write(f"• {name}")
1118
+
1119
+ with st.expander("Why this matters"):
1120
+ st.markdown("""
1121
+ Cookbooks need a valid `metadata.rb` file for proper analysis. Without it:
1122
+ - Version and maintainer information cannot be determined
1123
+ - Dependencies cannot be identified
1124
+ - Analysis may fail or produce incomplete results
1125
+
1126
+ **To fix:** Ensure each cookbook has a `metadata.rb` file with
1127
+ proper Ruby syntax.
1128
+ """)
1129
+
1130
+ # Check for cookbooks without recipes
1131
+ cookbooks_without_recipes = []
1132
+ for cookbook in cookbook_data:
1133
+ cookbook_dir = Path(cookbook["Path"])
1134
+ recipes_dir = cookbook_dir / "recipes"
1135
+ if not recipes_dir.exists() or not list(recipes_dir.glob("*.rb")):
1136
+ cookbooks_without_recipes.append(cookbook["Name"])
1137
+
1138
+ if cookbooks_without_recipes:
1139
+ st.warning("Some cookbooks may not have recipes:")
1140
+ st.markdown("**Cookbooks without recipe files:**")
1141
+ for name in cookbooks_without_recipes:
1142
+ st.write(f"• {name}")
1143
+
1144
+ with st.expander("Why this matters"):
1145
+ st.markdown("""
1146
+ Cookbooks need recipe files (`.rb` files in the `recipes/` directory)
1147
+ to be converted to Ansible.
1148
+ Without recipes, the cookbook cannot be analyzed or converted.
1149
+
1150
+ **To fix:** Ensure each cookbook has at least one `.rb` file in its
1151
+ `recipes/` directory.
1152
+ """)
1153
+
1154
+
1155
+ def _analyze_all_cookbooks_holistically(
1156
+ cookbook_path: str, cookbook_data: list
1157
+ ) -> None:
1158
+ """Analyse all cookbooks holistically."""
1159
+ st.subheader("Holistic Cookbook Analysis")
265
1160
 
1161
+ progress_bar, status_text = _setup_analysis_progress()
266
1162
 
267
- def _perform_cookbook_analysis(
268
- cookbook_path, selected_cookbooks, progress_bar, status_text
269
- ):
270
- """Perform analysis on selected cookbooks."""
271
- results = []
272
- total = len(selected_cookbooks)
1163
+ try:
1164
+ status_text.text("Performing holistic analysis of all cookbooks...")
1165
+
1166
+ # Check if AI-enhanced analysis is available
1167
+ ai_config = load_ai_settings()
1168
+ provider_name = _get_ai_provider(ai_config)
1169
+ use_ai = (
1170
+ provider_name
1171
+ and provider_name != LOCAL_PROVIDER
1172
+ and ai_config.get("api_key")
1173
+ )
273
1174
 
274
- for i, cookbook_name in enumerate(selected_cookbooks):
275
- _update_progress(status_text, cookbook_name, i + 1, total)
276
- progress_bar.progress((i + 1) / total)
1175
+ if use_ai:
1176
+ results = _analyze_with_ai(cookbook_data, provider_name, progress_bar)
1177
+ assessment_result = {
1178
+ "cookbook_assessments": results,
1179
+ "recommendations": "AI-enhanced per-cookbook recommendations above",
1180
+ }
1181
+ st.session_state.analysis_info_messages = [
1182
+ f"Using AI-enhanced analysis with {provider_name} "
1183
+ f"({_get_ai_string_value(ai_config, 'model', 'claude-3-5-sonnet-20241022')})", # noqa: E501
1184
+ f"Detected {len(cookbook_data)} cookbook(s)",
1185
+ ]
1186
+ else:
1187
+ results, assessment_result = _analyze_rule_based(cookbook_data)
277
1188
 
278
- cookbook_dir = _find_cookbook_directory(cookbook_path, cookbook_name)
279
- if cookbook_dir:
280
- analysis_result = _analyze_single_cookbook(cookbook_name, cookbook_dir)
281
- results.append(analysis_result)
1189
+ st.session_state.holistic_assessment = assessment_result
1190
+ st.session_state.analysis_results = results
1191
+ st.session_state.analysis_cookbook_path = cookbook_path
1192
+ st.session_state.total_cookbooks = len(results)
282
1193
 
283
- return results
1194
+ progress_bar.progress(1.0)
1195
+ st.rerun()
284
1196
 
1197
+ except Exception as e:
1198
+ progress_bar.empty()
1199
+ status_text.empty()
1200
+ st.error(f"Holistic analysis failed: {e}")
1201
+ finally:
1202
+ progress_bar.empty()
1203
+ status_text.empty()
1204
+
1205
+
1206
+ def _analyze_with_ai(
1207
+ cookbook_data: list,
1208
+ provider_name: str,
1209
+ progress_bar,
1210
+ ) -> list:
1211
+ """
1212
+ Analyze cookbooks using AI-enhanced analysis.
285
1213
 
286
- def _update_progress(status_text, cookbook_name, current, total):
287
- """Update progress display."""
288
- status_text.text(f"Analyzing {cookbook_name}... ({current}/{total})")
1214
+ Args:
1215
+ cookbook_data: List of cookbook data.
1216
+ provider_name: Name of the AI provider.
1217
+ progress_bar: Streamlit progress bar.
289
1218
 
1219
+ Returns:
1220
+ List of analysis results.
290
1221
 
291
- def _find_cookbook_directory(cookbook_path, cookbook_name):
292
- """Find the directory for a specific cookbook."""
293
- for d in Path(cookbook_path).iterdir():
294
- if d.is_dir() and d.name == cookbook_name:
295
- return d
296
- return None
1222
+ """
1223
+ from souschef.assessment import assess_single_cookbook_with_ai
1224
+
1225
+ ai_config = load_ai_settings()
1226
+ provider_mapping = {
1227
+ ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
1228
+ ANTHROPIC_PROVIDER: "anthropic",
1229
+ "OpenAI": "openai",
1230
+ OPENAI_PROVIDER: "openai",
1231
+ IBM_WATSONX: "watson",
1232
+ RED_HAT_LIGHTSPEED: "lightspeed",
1233
+ }
1234
+ provider = provider_mapping.get(
1235
+ provider_name,
1236
+ provider_name.lower().replace(" ", "_"),
1237
+ )
297
1238
 
1239
+ model = _get_ai_string_value(ai_config, "model", "claude-3-5-sonnet-20241022")
1240
+ api_key = _get_ai_string_value(ai_config, "api_key", "")
1241
+ temperature = _get_ai_float_value(ai_config, "temperature", 0.7)
1242
+ max_tokens = _get_ai_int_value(ai_config, "max_tokens", 4000)
1243
+ project_id = _get_ai_string_value(ai_config, "project_id", "")
1244
+ base_url = _get_ai_string_value(ai_config, "base_url", "")
298
1245
 
299
- def _analyze_single_cookbook(cookbook_name, cookbook_dir):
300
- """Analyze a single cookbook."""
301
- try:
302
- assessment = parse_chef_migration_assessment(str(cookbook_dir))
303
- metadata = parse_cookbook_metadata(str(cookbook_dir / "metadata.rb"))
1246
+ st.info(f"Using AI-enhanced analysis with {provider_name} ({model})")
304
1247
 
305
- return _create_successful_analysis(
306
- cookbook_name, cookbook_dir, assessment, metadata
1248
+ # Count total recipes across all cookbooks
1249
+ total_recipes = sum(
1250
+ len(list((Path(cb["Path"]) / "recipes").glob("*.rb")))
1251
+ if (Path(cb["Path"]) / "recipes").exists()
1252
+ else 0
1253
+ for cb in cookbook_data
1254
+ )
1255
+
1256
+ st.info(f"Detected {len(cookbook_data)} cookbook(s) with {total_recipes} recipe(s)")
1257
+
1258
+ results = []
1259
+ for i, cb_data in enumerate(cookbook_data):
1260
+ # Count recipes in this cookbook
1261
+ recipes_dir = Path(cb_data["Path"]) / "recipes"
1262
+ recipe_count = (
1263
+ len(list(recipes_dir.glob("*.rb"))) if recipes_dir.exists() else 0
307
1264
  )
308
- except Exception as e:
309
- return _create_failed_analysis(cookbook_name, cookbook_dir, str(e))
310
1265
 
1266
+ st.info(
1267
+ f"Analyzing {cb_data['Name']} ({recipe_count} recipes)... "
1268
+ f"({i + 1}/{len(cookbook_data)})"
1269
+ )
1270
+ progress_bar.progress((i + 1) / len(cookbook_data))
1271
+
1272
+ assessment = assess_single_cookbook_with_ai(
1273
+ cb_data["Path"],
1274
+ ai_provider=provider,
1275
+ api_key=api_key,
1276
+ model=model,
1277
+ temperature=temperature,
1278
+ max_tokens=max_tokens,
1279
+ project_id=project_id,
1280
+ base_url=base_url,
1281
+ )
311
1282
 
312
- def _create_successful_analysis(cookbook_name, cookbook_dir, assessment, metadata):
313
- """Create analysis result for successful analysis."""
314
- return {
315
- "name": cookbook_name,
316
- "path": str(cookbook_dir),
317
- "version": metadata.get("version", "Unknown"),
318
- "maintainer": metadata.get("maintainer", "Unknown"),
319
- "description": metadata.get("description", "No description"),
320
- "dependencies": len(metadata.get("depends", [])),
321
- "complexity": assessment.get("complexity", "Unknown"),
322
- "estimated_hours": assessment.get("estimated_hours", 0),
323
- "recommendations": assessment.get("recommendations", ""),
324
- "status": ANALYSIS_STATUS_ANALYZED,
325
- }
1283
+ result = _build_cookbook_result(cb_data, assessment, ANALYSIS_STATUS_ANALYSED)
1284
+ results.append(result)
326
1285
 
1286
+ return results
1287
+
1288
+
1289
+ def _analyze_rule_based(
1290
+ cookbook_data: list,
1291
+ ) -> tuple[list, dict]:
1292
+ """
1293
+ Analyze cookbooks using rule-based analysis.
1294
+
1295
+ Args:
1296
+ cookbook_data: List of cookbook data.
1297
+
1298
+ Returns:
1299
+ Tuple of (results list, assessment_result dict).
1300
+
1301
+ """
1302
+ from souschef.assessment import parse_chef_migration_assessment
1303
+
1304
+ cookbook_paths_list = [cb["Path"] for cb in cookbook_data]
1305
+ cookbook_paths_str = ",".join(cookbook_paths_list)
1306
+
1307
+ assessment_result = parse_chef_migration_assessment(cookbook_paths_str)
1308
+
1309
+ if "error" in assessment_result:
1310
+ st.error(f"Holistic analysis failed: {assessment_result['error']}")
1311
+ return [], {}
1312
+
1313
+ results = _process_cookbook_assessments(assessment_result, cookbook_data)
1314
+ return results, assessment_result
1315
+
1316
+
1317
+ def _process_cookbook_assessments(assessment_result: dict, cookbook_data: list) -> list:
1318
+ """
1319
+ Process cookbook assessments and build results.
1320
+
1321
+ Args:
1322
+ assessment_result: Assessment result dictionary.
1323
+ cookbook_data: List of cookbook data.
1324
+
1325
+ Returns:
1326
+ List of result dictionaries.
1327
+
1328
+ """
1329
+ results: list[dict] = []
1330
+ if "cookbook_assessments" not in assessment_result:
1331
+ return results
1332
+
1333
+ top_recommendations = assessment_result.get("recommendations", "")
1334
+
1335
+ for cookbook_assessment in assessment_result["cookbook_assessments"]:
1336
+ result = _build_assessment_result(
1337
+ cookbook_assessment, cookbook_data, top_recommendations
1338
+ )
1339
+ results.append(result)
1340
+
1341
+ return results
1342
+
1343
+
1344
+ def _build_assessment_result(
1345
+ cookbook_assessment: dict, cookbook_data: list, top_recommendations: str
1346
+ ) -> dict:
1347
+ """
1348
+ Build result dictionary from cookbook assessment.
1349
+
1350
+ Args:
1351
+ cookbook_assessment: Single cookbook assessment.
1352
+ cookbook_data: List of cookbook data.
1353
+ top_recommendations: Top-level recommendations.
1354
+
1355
+ Returns:
1356
+ Result dictionary.
1357
+
1358
+ """
1359
+ cookbook_path = cookbook_assessment.get("cookbook_path", "")
1360
+ cookbook_info = _find_cookbook_info(cookbook_data, cookbook_path)
1361
+
1362
+ recommendations = _build_recommendations(cookbook_assessment, top_recommendations)
1363
+
1364
+ estimated_days = cookbook_assessment.get("estimated_effort_days", 0)
1365
+ effort_metrics = EffortMetrics(estimated_days)
1366
+
1367
+ return {
1368
+ "name": (
1369
+ cookbook_info["Name"]
1370
+ if cookbook_info
1371
+ else cookbook_assessment["cookbook_name"]
1372
+ ),
1373
+ "path": cookbook_info["Path"] if cookbook_info else cookbook_path,
1374
+ "version": cookbook_info["Version"] if cookbook_info else "Unknown",
1375
+ "maintainer": cookbook_info["Maintainer"] if cookbook_info else "Unknown",
1376
+ "description": (
1377
+ cookbook_info["Description"] if cookbook_info else "Analysed holistically"
1378
+ ),
1379
+ "dependencies": int(cookbook_assessment.get("dependencies", 0) or 0),
1380
+ "complexity": cookbook_assessment.get("migration_priority", "Unknown").title(),
1381
+ "estimated_hours": effort_metrics.estimated_hours,
1382
+ "recommendations": recommendations,
1383
+ "status": ANALYSIS_STATUS_ANALYSED,
1384
+ }
1385
+
1386
+
1387
+ def _find_cookbook_info(cookbook_data: list, cookbook_path: str) -> dict | None:
1388
+ """
1389
+ Find cookbook info matching the given path.
1390
+
1391
+ Args:
1392
+ cookbook_data: List of cookbook data.
1393
+ cookbook_path: Path to match.
1394
+
1395
+ Returns:
1396
+ Matching cookbook info or None.
1397
+
1398
+ """
1399
+ return next(
1400
+ (cd for cd in cookbook_data if cd["Path"] == cookbook_path),
1401
+ None,
1402
+ )
1403
+
1404
+
1405
+ def _build_cookbook_result(cb_data: dict, assessment: dict, status: str) -> dict:
1406
+ """
1407
+ Build a cookbook result from assessment data.
1408
+
1409
+ Args:
1410
+ cb_data: Cookbook data.
1411
+ assessment: Assessment dictionary.
1412
+ status: Status of analysis.
1413
+
1414
+ Returns:
1415
+ Result dictionary.
1416
+
1417
+ """
1418
+ if "error" not in assessment:
1419
+ return {
1420
+ "name": cb_data["Name"],
1421
+ "path": cb_data["Path"],
1422
+ "version": cb_data["Version"],
1423
+ "maintainer": cb_data["Maintainer"],
1424
+ "description": cb_data["Description"],
1425
+ "dependencies": cb_data["Dependencies"],
1426
+ "complexity": assessment.get("complexity", "Unknown"),
1427
+ "estimated_hours": assessment.get("estimated_hours", 0),
1428
+ "recommendations": assessment.get(
1429
+ "recommendations", "No recommendations available"
1430
+ ),
1431
+ "status": status,
1432
+ }
1433
+ return {
1434
+ "name": cb_data["Name"],
1435
+ "path": cb_data["Path"],
1436
+ "version": cb_data["Version"],
1437
+ "maintainer": cb_data["Maintainer"],
1438
+ "description": cb_data["Description"],
1439
+ "dependencies": cb_data["Dependencies"],
1440
+ "complexity": "Error",
1441
+ "estimated_hours": 0,
1442
+ "recommendations": f"Analysis failed: {assessment['error']}",
1443
+ "status": ANALYSIS_STATUS_FAILED,
1444
+ }
1445
+
1446
+
1447
+ def _build_recommendations(cookbook_assessment: dict, top_recommendations: str) -> str:
1448
+ """
1449
+ Build recommendations from cookbook assessment.
1450
+
1451
+ Args:
1452
+ cookbook_assessment: Assessment data for a cookbook.
1453
+ top_recommendations: Top-level recommendations.
1454
+
1455
+ Returns:
1456
+ Formatted recommendations string.
1457
+
1458
+ """
1459
+ recommendations: list[str] = []
1460
+ if cookbook_assessment.get("challenges"):
1461
+ for challenge in cookbook_assessment["challenges"]:
1462
+ recommendations.append(f"• {challenge}")
1463
+ return "\n".join(recommendations)
1464
+
1465
+ return (
1466
+ top_recommendations
1467
+ if top_recommendations
1468
+ else f"Complexity: {str(cookbook_assessment.get('complexity_score', 0))}/100"
1469
+ )
1470
+
1471
+
1472
+ def _convert_all_cookbooks_holistically(cookbook_path: str):
1473
+ """Convert all cookbooks to Ansible roles."""
1474
+ st.subheader("Holistic Cookbook Conversion")
1475
+
1476
+ progress_bar, status_text = _setup_analysis_progress()
1477
+
1478
+ try:
1479
+ status_text.text("Converting all cookbooks holistically...")
1480
+
1481
+ # Create temporary output directory with secure permissions
1482
+ import tempfile
1483
+ from pathlib import Path
1484
+
1485
+ output_dir = Path(tempfile.mkdtemp(prefix="souschef_holistic_conversion_"))
1486
+ with contextlib.suppress(FileNotFoundError, OSError):
1487
+ output_dir.chmod(0o700) # Secure permissions: rwx------
1488
+
1489
+ # Get assessment data if available
1490
+ assessment_data = ""
1491
+ if (
1492
+ "holistic_assessment" in st.session_state
1493
+ and st.session_state.holistic_assessment
1494
+ ):
1495
+ assessment_data = json.dumps(st.session_state.holistic_assessment)
1496
+
1497
+ # Call the new holistic conversion function
1498
+ from souschef.server import convert_all_cookbooks_comprehensive
1499
+
1500
+ conversion_result = convert_all_cookbooks_comprehensive(
1501
+ cookbooks_path=cookbook_path,
1502
+ output_path=str(output_dir),
1503
+ assessment_data=assessment_data,
1504
+ include_templates=True,
1505
+ include_attributes=True,
1506
+ include_recipes=True,
1507
+ )
1508
+
1509
+ if conversion_result.startswith("Error"):
1510
+ st.error(f"Holistic conversion failed: {conversion_result}")
1511
+ return
1512
+
1513
+ # Store conversion result for display
1514
+ st.session_state.holistic_conversion_result = {
1515
+ "result": conversion_result,
1516
+ "output_path": str(output_dir),
1517
+ }
1518
+
1519
+ progress_bar.progress(1.0)
1520
+ status_text.text("Holistic conversion completed!")
1521
+ st.success("Holistically converted all cookbooks to Ansible roles!")
1522
+
1523
+ # Display conversion results
1524
+ _display_holistic_conversion_results(
1525
+ st.session_state.holistic_conversion_result
1526
+ )
1527
+
1528
+ # Trigger rerun to display results
1529
+ st.rerun()
1530
+
1531
+ except Exception as e:
1532
+ progress_bar.empty()
1533
+ status_text.empty()
1534
+ st.error(f"Holistic conversion failed: {e}")
1535
+ finally:
1536
+ progress_bar.empty()
1537
+ status_text.empty()
1538
+
1539
+
1540
+ def _parse_conversion_result_text(result_text: str) -> dict:
1541
+ """Parse the conversion result text to extract structured data."""
1542
+ structured: dict[str, Any] = {
1543
+ "summary": {},
1544
+ "cookbook_results": [],
1545
+ "warnings": [],
1546
+ "errors": [],
1547
+ }
1548
+
1549
+ lines = result_text.split("\n")
1550
+ current_section = None
1551
+
1552
+ for line in lines:
1553
+ line = line.strip()
1554
+
1555
+ # Parse summary section
1556
+ if "## Overview:" in line:
1557
+ current_section = "summary"
1558
+ elif current_section == "summary" and "- " in line:
1559
+ _parse_summary_line(line, structured)
1560
+
1561
+ # Parse successfully converted cookbooks
1562
+ elif "## Successfully Converted Cookbooks:" in line:
1563
+ current_section = "converted"
1564
+ elif current_section == "converted" and line.startswith("- **"):
1565
+ _parse_converted_cookbook(line, structured)
1566
+
1567
+ # Parse failed conversions
1568
+ elif "## Failed Conversions:" in line:
1569
+ current_section = "failed"
1570
+ elif current_section == "failed" and line.startswith("- ❌ **"):
1571
+ _parse_failed_cookbook(line, structured)
1572
+
1573
+ # Extract warnings from the result text
1574
+ _extract_warnings_from_text(result_text, structured)
1575
+
1576
+ return structured
1577
+
1578
+
1579
+ def _parse_summary_line(line: str, structured: dict):
1580
+ """Parse a single summary line."""
1581
+ if "Total cookbooks found:" in line:
1582
+ try:
1583
+ count = int(line.split(":")[-1].strip())
1584
+ structured["summary"]["total_cookbooks"] = count
1585
+ except ValueError:
1586
+ pass
1587
+ elif "Successfully converted:" in line:
1588
+ try:
1589
+ count = int(line.split(":")[-1].strip())
1590
+ structured["summary"]["cookbooks_converted"] = count
1591
+ except ValueError:
1592
+ pass
1593
+ elif "Total files converted:" in line:
1594
+ try:
1595
+ count = int(line.split(":")[-1].strip())
1596
+ structured["summary"]["total_converted_files"] = count
1597
+ except ValueError:
1598
+ pass
1599
+
1600
+
1601
+ def _parse_converted_cookbook(line: str, structured: dict):
1602
+ """Parse a successfully converted cookbook line."""
1603
+ try:
1604
+ parts = line.split("**")
1605
+ if len(parts) >= 3:
1606
+ cookbook_name = parts[1]
1607
+ role_name = parts[3].strip("`→ ")
1608
+ structured["cookbook_results"].append(
1609
+ {
1610
+ "cookbook_name": cookbook_name,
1611
+ "role_name": role_name,
1612
+ "status": "success",
1613
+ "tasks_count": 0, # Will be updated if more details available
1614
+ "templates_count": 0,
1615
+ "variables_count": 0,
1616
+ "files_count": 0,
1617
+ }
1618
+ )
1619
+ except (IndexError, ValueError):
1620
+ pass
1621
+
1622
+
1623
+ def _parse_failed_cookbook(line: str, structured: dict):
1624
+ """Parse a failed conversion cookbook line."""
1625
+ try:
1626
+ parts = line.split("**")
1627
+ if len(parts) >= 3:
1628
+ cookbook_name = parts[1]
1629
+ error = parts[3].strip(": ")
1630
+ structured["cookbook_results"].append(
1631
+ {
1632
+ "cookbook_name": cookbook_name,
1633
+ "status": "failed",
1634
+ "error": error,
1635
+ }
1636
+ )
1637
+ except (IndexError, ValueError):
1638
+ pass
1639
+
1640
+
1641
+ def _extract_warnings_from_text(result_text: str, structured: dict):
1642
+ """Extract warnings from the conversion result text."""
1643
+ # Extract warnings from the result text (look for common warning patterns)
1644
+ if "No recipes directory found" in result_text:
1645
+ structured["warnings"].append(
1646
+ "Some cookbooks are missing recipes directories and cannot be "
1647
+ "converted to Ansible tasks"
1648
+ )
1649
+ if "No recipe files" in result_text.lower():
1650
+ structured["warnings"].append("Some cookbooks have empty recipes directories")
1651
+
1652
+ # If no cookbooks were successfully converted but some were found,
1653
+ # add a general warning
1654
+ total_found = structured["summary"].get("total_cookbooks", 0)
1655
+ converted = structured["summary"].get("cookbooks_converted", 0)
1656
+ if total_found > 0 and converted == 0:
1657
+ structured["warnings"].append(
1658
+ "No cookbooks were successfully converted. Check that cookbooks "
1659
+ "contain recipes directories with .rb files."
1660
+ )
1661
+
1662
+
1663
+ def _display_holistic_conversion_results(conversion_result: dict):
1664
+ """Display the results of holistic cookbook conversion."""
1665
+ st.subheader("Holistic Conversion Results")
1666
+
1667
+ # Parse the conversion result string to extract structured data
1668
+ result_text = conversion_result.get("result", "")
1669
+ structured_result = _parse_conversion_result_text(result_text)
1670
+
1671
+ _display_conversion_summary(structured_result)
1672
+ _display_conversion_warnings_errors(structured_result)
1673
+ _display_conversion_details(structured_result)
1674
+ _display_conversion_report(result_text)
1675
+ _display_conversion_download_options(conversion_result)
1676
+
1677
+
1678
+ def _display_conversion_summary(structured_result: dict):
1679
+ """Display the conversion summary metrics."""
1680
+ if "summary" in structured_result:
1681
+ summary = structured_result["summary"]
1682
+ col1, col2, col3, col4 = st.columns(4)
1683
+
1684
+ with col1:
1685
+ st.metric("Cookbooks Converted", summary.get("cookbooks_converted", 0))
1686
+
1687
+ with col2:
1688
+ st.metric("Roles Created", summary.get("roles_created", 0))
1689
+
1690
+ with col3:
1691
+ st.metric("Tasks Generated", summary.get("tasks_generated", 0))
1692
+
1693
+ with col4:
1694
+ st.metric("Templates Converted", summary.get("templates_converted", 0))
1695
+
1696
+
1697
+ def _display_conversion_warnings_errors(structured_result: dict):
1698
+ """Display conversion warnings and errors."""
1699
+ if "warnings" in structured_result and structured_result["warnings"]:
1700
+ st.warning("⚠️ Conversion Warnings")
1701
+ for warning in structured_result["warnings"]:
1702
+ st.write(f"• {warning}")
1703
+
1704
+ if "errors" in structured_result and structured_result["errors"]:
1705
+ st.error("❌ Conversion Errors")
1706
+ for error in structured_result["errors"]:
1707
+ st.write(f"• {error}")
1708
+
1709
+
1710
+ def _display_conversion_details(structured_result: dict):
1711
+ """Display detailed conversion results."""
1712
+ if "cookbook_results" in structured_result:
1713
+ st.subheader("Conversion Details")
1714
+
1715
+ for cookbook_result in structured_result["cookbook_results"]:
1716
+ with st.expander(
1717
+ f"📁 {cookbook_result.get('cookbook_name', 'Unknown')}", expanded=False
1718
+ ):
1719
+ col1, col2 = st.columns(2)
1720
+
1721
+ with col1:
1722
+ st.metric("Tasks", cookbook_result.get("tasks_count", 0))
1723
+ st.metric("Templates", cookbook_result.get("templates_count", 0))
1724
+
1725
+ with col2:
1726
+ st.metric("Variables", cookbook_result.get("variables_count", 0))
1727
+ st.metric("Files", cookbook_result.get("files_count", 0))
1728
+
1729
+ if cookbook_result.get("status") == "success":
1730
+ st.success("✅ Conversion successful")
1731
+ else:
1732
+ error_msg = cookbook_result.get("error", "Unknown error")
1733
+ st.error(f"❌ Conversion failed: {error_msg}")
1734
+
1735
+
1736
+ def _display_conversion_report(result_text: str):
1737
+ """Display the raw conversion report."""
1738
+ with st.expander("Full Conversion Report"):
1739
+ st.code(result_text, language="markdown")
1740
+
1741
+
1742
+ def _display_conversion_download_options(conversion_result: dict):
1743
+ """Display download options for converted roles."""
1744
+ if "output_path" in conversion_result:
1745
+ st.subheader("Download Converted Roles")
1746
+
1747
+ # Validate output_path before use
1748
+ output_path = conversion_result["output_path"]
1749
+ try:
1750
+ from souschef.core.path_utils import _normalize_path
1751
+
1752
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
1753
+ safe_output_path = _normalize_path(str(output_path))
1754
+ except ValueError:
1755
+ st.error("Invalid output path")
1756
+ return
1757
+
1758
+ if safe_output_path.exists():
1759
+ # Create ZIP archive of all converted roles
1760
+ zip_buffer = io.BytesIO()
1761
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
1762
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
1763
+ for root, _dirs, files in os.walk(str(safe_output_path)):
1764
+ for file in files:
1765
+ file_path = Path(root) / file
1766
+ arcname = file_path.relative_to(safe_output_path)
1767
+ zip_file.write(str(file_path), str(arcname))
1768
+
1769
+ zip_buffer.seek(0)
1770
+
1771
+ st.download_button(
1772
+ label="📦 Download All Ansible Roles",
1773
+ data=zip_buffer.getvalue(),
1774
+ file_name="ansible_roles_holistic.zip",
1775
+ mime="application/zip",
1776
+ help="Download ZIP archive containing all converted Ansible roles",
1777
+ key="download_holistic_roles",
1778
+ )
1779
+
1780
+ st.info(f"📂 Roles saved to: {output_path}")
1781
+ else:
1782
+ st.warning("Output directory not found for download")
1783
+
1784
+
1785
+ def _handle_dashboard_upload():
1786
+ """Handle file uploaded from the dashboard."""
1787
+ # Create a file-like object from the stored data
1788
+ file_data = st.session_state.uploaded_file_data
1789
+ file_name = st.session_state.uploaded_file_name
1790
+
1791
+ # Create a file-like object that mimics the UploadedFile interface
1792
+ class MockUploadedFile:
1793
+ def __init__(self, data, name, mime_type):
1794
+ self.data = data
1795
+ self.name = name
1796
+ self.type = mime_type
1797
+
1798
+ def getbuffer(self):
1799
+ return self.data
1800
+
1801
+ def getvalue(self):
1802
+ return self.data
1803
+
1804
+ mock_file = MockUploadedFile(
1805
+ file_data, file_name, st.session_state.uploaded_file_type
1806
+ )
1807
+
1808
+ # Display upload info
1809
+ st.info(f"Using file uploaded from Dashboard: {file_name}")
1810
+
1811
+ # Add option to clear and upload a different file
1812
+ col1, col2 = st.columns([1, 1])
1813
+ with col1:
1814
+ if st.button(
1815
+ "Use Different File",
1816
+ help="Clear this file and upload a different one",
1817
+ key="use_different_file",
1818
+ ):
1819
+ # Clear the uploaded file from session state
1820
+ del st.session_state.uploaded_file_data
1821
+ del st.session_state.uploaded_file_name
1822
+ del st.session_state.uploaded_file_type
1823
+ st.rerun()
1824
+
1825
+ with col2:
1826
+ if st.button(
1827
+ "Back to Dashboard", help="Return to dashboard", key="back_to_dashboard"
1828
+ ):
1829
+ st.session_state.current_page = "Dashboard"
1830
+ st.rerun()
1831
+
1832
+ # Process the file
1833
+ try:
1834
+ with st.spinner("Extracting archive..."):
1835
+ temp_dir, cookbook_path = extract_archive(mock_file)
1836
+ # Store temp_dir in session state to prevent premature cleanup
1837
+ st.session_state.temp_dir = temp_dir
1838
+ st.success("Archive extracted successfully!")
1839
+
1840
+ # Validate and list cookbooks
1841
+ if cookbook_path:
1842
+ _validate_and_list_cookbooks(str(cookbook_path))
1843
+
1844
+ except Exception as e:
1845
+ st.error(f"Failed to process uploaded file: {e}")
1846
+ # Clear the uploaded file on error
1847
+ if "uploaded_file_data" in st.session_state:
1848
+ del st.session_state.uploaded_file_data
1849
+ del st.session_state.uploaded_file_name
1850
+ del st.session_state.uploaded_file_type
1851
+
1852
+
1853
+ def _display_instructions():
1854
+ """Display usage instructions."""
1855
+ with st.expander("How to Use"):
1856
+ st.markdown("""
1857
+ ## Input Methods
1858
+
1859
+ ### Directory Path
1860
+ 1. **Enter Cookbook Path**: Provide a **relative path** to your cookbooks
1861
+ (absolute paths not allowed)
1862
+ 2. **Review Cookbooks**: The interface will list all cookbooks with metadata
1863
+ 3. **Select Cookbooks**: Choose which cookbooks to analyse
1864
+ 4. **Run Analysis**: Click "Analyse Selected Cookbooks" to get detailed insights
1865
+
1866
+ **Path Examples:**
1867
+ - `cookbooks/` - subdirectory in current workspace
1868
+ - `../shared/cookbooks/` - parent directory
1869
+ - `./my-cookbooks/` - explicit current directory
1870
+
1871
+ ### Archive Upload
1872
+ 1. **Upload Archive**: Upload a ZIP or TAR archive containing your cookbooks
1873
+ 2. **Automatic Extraction**: The system will extract and analyse the archive
1874
+
1875
+ 3. **Review Cookbooks**: Interface will list all cookbooks found in archive
1876
+ 4. **Select Cookbooks**: Choose which cookbooks to analyse
1877
+ 5. **Run Analysis**: Click "Analyse Selected Cookbooks" to get insights
1878
+
1879
+
1880
+ ## Expected Structure
1881
+ ```
1882
+ cookbooks/ or archive.zip/
1883
+ ├── nginx/
1884
+ │ ├── metadata.rb
1885
+ │ ├── recipes/
1886
+ │ └── attributes/
1887
+ ├── apache2/
1888
+ │ └── metadata.rb
1889
+ └── mysql/
1890
+ └── metadata.rb
1891
+ ```
1892
+
1893
+ ## Supported Archive Formats
1894
+ - ZIP (.zip)
1895
+ - TAR (.tar)
1896
+ - GZIP-compressed TAR (.tar.gz, .tgz)
1897
+ """)
1898
+
1899
+
1900
+ def analyse_selected_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
1901
+ """Analyse the selected cookbooks and store results in session state."""
1902
+ st.subheader("Analysis Results")
1903
+
1904
+ progress_bar, status_text = _setup_analysis_progress()
1905
+ results = _perform_cookbook_analysis(
1906
+ cookbook_path, selected_cookbooks, progress_bar, status_text
1907
+ )
1908
+
1909
+ _cleanup_progress_indicators(progress_bar, status_text)
1910
+
1911
+ # Store results in session state
1912
+ st.session_state.analysis_results = results
1913
+ st.session_state.analysis_cookbook_path = cookbook_path
1914
+ st.session_state.total_cookbooks = len(selected_cookbooks)
1915
+
1916
+ # Trigger rerun to display results
1917
+ st.rerun()
1918
+
1919
+
1920
+ def _setup_analysis_progress():
1921
+ """Set up progress tracking for analysis."""
1922
+ progress_bar = st.progress(0)
1923
+ status_text = st.empty()
1924
+ return progress_bar, status_text
1925
+
1926
+
1927
+ def _perform_cookbook_analysis(
1928
+ cookbook_path, selected_cookbooks, progress_bar, status_text
1929
+ ):
1930
+ """Perform analysis on selected cookbooks."""
1931
+ results = []
1932
+ total = len(selected_cookbooks)
1933
+
1934
+ for i, cookbook_name in enumerate(selected_cookbooks):
1935
+ _update_progress(status_text, cookbook_name, i + 1, total)
1936
+ progress_bar.progress((i + 1) / total)
1937
+
1938
+ cookbook_dir = _find_cookbook_directory(cookbook_path, cookbook_name)
1939
+ if cookbook_dir:
1940
+ analysis_result = _analyse_single_cookbook(cookbook_name, cookbook_dir)
1941
+ results.append(analysis_result)
1942
+
1943
+ return results
1944
+
1945
+
1946
+ def _update_progress(status_text, cookbook_name, current, total):
1947
+ """Update progress display."""
1948
+ # Check if AI is configured
1949
+ ai_config = load_ai_settings()
1950
+ ai_available = (
1951
+ ai_config.get("provider")
1952
+ and ai_config.get("provider") != LOCAL_PROVIDER
1953
+ and ai_config.get("api_key")
1954
+ )
1955
+
1956
+ ai_indicator = " [AI-ENHANCED]" if ai_available else " [RULE-BASED]"
1957
+ status_text.text(f"Analyzing {cookbook_name}{ai_indicator}... ({current}/{total})")
1958
+
1959
+
1960
+ def _find_cookbook_directory(cookbook_path, cookbook_name):
1961
+ """Find the directory for a specific cookbook by checking metadata."""
1962
+ for d in Path(cookbook_path).iterdir():
1963
+ if d.is_dir():
1964
+ # Check if this directory contains a cookbook with the matching name
1965
+ metadata_file = d / METADATA_FILENAME
1966
+ if metadata_file.exists():
1967
+ try:
1968
+ metadata = parse_cookbook_metadata(str(metadata_file))
1969
+ if metadata.get("name") == cookbook_name:
1970
+ return d
1971
+ except Exception:
1972
+ # If metadata parsing fails, skip this directory
1973
+ continue
1974
+ return None
1975
+
1976
+
1977
+ def _analyse_single_cookbook(cookbook_name, cookbook_dir):
1978
+ """Analyse a single cookbook."""
1979
+ try:
1980
+ metadata = _load_cookbook_metadata(cookbook_name, cookbook_dir)
1981
+ if "error" in metadata:
1982
+ return metadata # Return error result
1983
+
1984
+ ai_config = load_ai_settings()
1985
+ use_ai = _should_use_ai(ai_config)
1986
+
1987
+ if use_ai:
1988
+ assessment = _run_ai_analysis(cookbook_dir, ai_config)
1989
+ else:
1990
+ assessment = _run_rule_based_analysis(cookbook_dir)
1991
+
1992
+ if isinstance(assessment, dict) and "error" in assessment:
1993
+ return _create_failed_analysis(
1994
+ cookbook_name, cookbook_dir, assessment["error"]
1995
+ )
1996
+
1997
+ return _create_successful_analysis(
1998
+ cookbook_name, cookbook_dir, assessment, metadata
1999
+ )
2000
+ except Exception as e:
2001
+ import traceback
2002
+
2003
+ error_details = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2004
+ return _create_failed_analysis(cookbook_name, cookbook_dir, error_details)
2005
+
2006
+
2007
+ def _load_cookbook_metadata(cookbook_name: str, cookbook_dir: Path) -> dict[str, Any]:
2008
+ """
2009
+ Load and parse cookbook metadata.
2010
+
2011
+ Args:
2012
+ cookbook_name: Name of the cookbook.
2013
+ cookbook_dir: Directory containing the cookbook.
2014
+
2015
+ Returns:
2016
+ Metadata dictionary or error result.
2017
+
2018
+ """
2019
+ metadata_file = cookbook_dir / METADATA_FILENAME
2020
+ if not metadata_file.exists():
2021
+ return _create_failed_analysis( # type: ignore[no-any-return]
2022
+ cookbook_name,
2023
+ cookbook_dir,
2024
+ f"No {METADATA_FILENAME} found in {cookbook_dir}",
2025
+ )
2026
+
2027
+ try:
2028
+ return parse_cookbook_metadata(str(metadata_file))
2029
+ except Exception as e:
2030
+ return _create_failed_analysis( # type: ignore[no-any-return]
2031
+ cookbook_name, cookbook_dir, f"Failed to parse metadata: {e}"
2032
+ )
2033
+
2034
+
2035
+ def _should_use_ai(ai_config: dict) -> bool:
2036
+ """
2037
+ Check if AI-enhanced analysis should be used.
2038
+
2039
+ Args:
2040
+ ai_config: AI configuration dictionary.
2041
+
2042
+ Returns:
2043
+ True if AI analysis should be used.
2044
+
2045
+ """
2046
+ return bool(
2047
+ ai_config.get("provider")
2048
+ and ai_config.get("provider") != LOCAL_PROVIDER
2049
+ and ai_config.get("api_key")
2050
+ )
2051
+
2052
+
2053
+ def _run_ai_analysis(cookbook_dir: Path, ai_config: dict) -> dict:
2054
+ """
2055
+ Run AI-enhanced cookbook analysis.
2056
+
2057
+ Args:
2058
+ cookbook_dir: Directory containing the cookbook.
2059
+ ai_config: AI configuration dictionary.
2060
+
2061
+ Returns:
2062
+ Assessment dictionary.
2063
+
2064
+ """
2065
+ ai_provider = _determine_ai_provider(ai_config)
2066
+
2067
+ return assess_single_cookbook_with_ai(
2068
+ str(cookbook_dir),
2069
+ ai_provider=ai_provider or "anthropic",
2070
+ api_key=str(ai_config.get("api_key", "")),
2071
+ model=str(ai_config.get("model", "claude-3-5-sonnet-20241022")),
2072
+ temperature=float(ai_config.get("temperature", 0.7)),
2073
+ max_tokens=int(ai_config.get("max_tokens", 4000)),
2074
+ project_id=str(ai_config.get("project_id", "")),
2075
+ base_url=str(ai_config.get("base_url", "")),
2076
+ )
2077
+
2078
+
2079
+ def _determine_ai_provider(ai_config: dict) -> str:
2080
+ """
2081
+ Determine AI provider name from config.
2082
+
2083
+ Args:
2084
+ ai_config: AI configuration dictionary.
2085
+
2086
+ Returns:
2087
+ Provider string.
2088
+
2089
+ """
2090
+ provider_mapping = {
2091
+ ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
2092
+ ANTHROPIC_PROVIDER: "anthropic",
2093
+ "OpenAI": "openai",
2094
+ OPENAI_PROVIDER: "openai",
2095
+ IBM_WATSONX: "watson",
2096
+ RED_HAT_LIGHTSPEED: "lightspeed",
2097
+ }
2098
+ provider_name_raw = ai_config.get("provider", "")
2099
+ provider_name = str(provider_name_raw) if provider_name_raw else ""
2100
+ return provider_mapping.get(
2101
+ provider_name,
2102
+ provider_name.lower().replace(" ", "_") if provider_name else "anthropic",
2103
+ )
2104
+
2105
+
2106
+ def _run_rule_based_analysis(cookbook_dir: Path) -> dict:
2107
+ """
2108
+ Run rule-based cookbook analysis.
2109
+
2110
+ Args:
2111
+ cookbook_dir: Directory containing the cookbook.
2112
+
2113
+ Returns:
2114
+ Assessment dictionary.
2115
+
2116
+ """
2117
+ from souschef.assessment import parse_chef_migration_assessment
2118
+
2119
+ assessment = parse_chef_migration_assessment(str(cookbook_dir))
2120
+
2121
+ # Extract single cookbook assessment if multi-cookbook structure returned
2122
+ if "cookbook_assessments" in assessment and assessment["cookbook_assessments"]:
2123
+ cookbook_assessment = assessment["cookbook_assessments"][0]
2124
+ return {
2125
+ "complexity": assessment.get("complexity", "Unknown"),
2126
+ "estimated_hours": assessment.get("estimated_hours", 0),
2127
+ "recommendations": _format_recommendations_from_assessment(
2128
+ cookbook_assessment, assessment
2129
+ ),
2130
+ }
2131
+
2132
+ return assessment
2133
+
2134
+
2135
+ def _create_successful_analysis(
2136
+ cookbook_name: str, cookbook_dir: Path, assessment: dict, metadata: dict
2137
+ ) -> dict:
2138
+ """Create analysis result for successful analysis."""
2139
+ return {
2140
+ "name": cookbook_name,
2141
+ "path": str(cookbook_dir),
2142
+ "version": metadata.get("version", "Unknown"),
2143
+ "maintainer": metadata.get("maintainer", "Unknown"),
2144
+ "description": metadata.get("description", "No description"),
2145
+ "dependencies": len(metadata.get("depends", [])),
2146
+ "complexity": assessment.get("complexity", "Unknown"),
2147
+ "estimated_hours": assessment.get("estimated_hours", 0),
2148
+ "recommendations": assessment.get("recommendations", ""),
2149
+ "status": ANALYSIS_STATUS_ANALYSED,
2150
+ }
2151
+
2152
+
2153
+ def _format_recommendations_from_assessment(
2154
+ cookbook_assessment: dict, overall_assessment: dict
2155
+ ) -> str:
2156
+ """Format recommendations from the detailed assessment structure."""
2157
+ recommendations: list[str] = []
2158
+
2159
+ # Add cookbook-specific details
2160
+ _add_complexity_score(recommendations, cookbook_assessment)
2161
+ _add_effort_estimate(recommendations, cookbook_assessment)
2162
+ _add_migration_priority(recommendations, cookbook_assessment)
2163
+ _add_key_findings(recommendations, cookbook_assessment)
2164
+ _add_overall_recommendations(recommendations, overall_assessment)
2165
+
2166
+ return "\n".join(recommendations) if recommendations else "Analysis completed"
2167
+
2168
+
2169
+ def _add_complexity_score(recommendations: list[str], assessment: dict) -> None:
2170
+ """Add complexity score to recommendations."""
2171
+ if "complexity_score" in assessment:
2172
+ recommendations.append(
2173
+ f"Complexity Score: {assessment['complexity_score']}/100"
2174
+ )
2175
+
2176
+
2177
+ def _add_effort_estimate(recommendations: list[str], assessment: dict) -> None:
2178
+ """Add effort estimate to recommendations."""
2179
+ if "estimated_effort_days" not in assessment:
2180
+ return
2181
+
2182
+ estimated_days = assessment["estimated_effort_days"]
2183
+ effort_metrics = EffortMetrics(estimated_days)
2184
+ complexity = assessment.get("complexity", "Medium")
2185
+ is_valid, _ = validate_metrics_consistency(
2186
+ days=effort_metrics.estimated_days,
2187
+ weeks=effort_metrics.estimated_weeks_range,
2188
+ hours=effort_metrics.estimated_hours,
2189
+ complexity=complexity,
2190
+ )
2191
+ if is_valid:
2192
+ recommendations.append(
2193
+ f"Estimated Effort: {effort_metrics.estimated_days_formatted}"
2194
+ )
2195
+ else:
2196
+ recommendations.append(f"Estimated Effort: {estimated_days} days")
2197
+
2198
+
2199
+ def _add_migration_priority(recommendations: list[str], assessment: dict) -> None:
2200
+ """Add migration priority to recommendations."""
2201
+ if "migration_priority" in assessment:
2202
+ recommendations.append(
2203
+ f"Migration Priority: {assessment['migration_priority']}"
2204
+ )
2205
+
2206
+
2207
+ def _add_key_findings(recommendations: list[str], assessment: dict) -> None:
2208
+ """Add key findings to recommendations."""
2209
+ if not assessment.get("key_findings"):
2210
+ return
2211
+
2212
+ recommendations.append("\nKey Findings:")
2213
+ for finding in assessment["key_findings"]:
2214
+ recommendations.append(f" - {finding}")
2215
+
2216
+
2217
+ def _add_overall_recommendations(
2218
+ recommendations: list[str], overall_assessment: dict
2219
+ ) -> None:
2220
+ """Add overall recommendations to recommendations."""
2221
+ rec_data = overall_assessment.get("recommendations")
2222
+ if not rec_data:
2223
+ return
2224
+
2225
+ recommendations.append("\nRecommendations:")
2226
+ if isinstance(rec_data, list):
2227
+ for rec in rec_data:
2228
+ if isinstance(rec, dict) and "recommendation" in rec:
2229
+ recommendations.append(f" - {rec['recommendation']}")
2230
+ elif isinstance(rec, str):
2231
+ recommendations.append(f" - {rec}")
2232
+ elif isinstance(rec_data, str):
2233
+ recommendations.append(f" - {rec_data}")
2234
+
2235
+
2236
+ def _get_error_context(cookbook_dir: Path) -> str:
2237
+ """Get context information about why analysis might have failed."""
2238
+ context_parts = []
2239
+
2240
+ # Check basic structure
2241
+ validation = _validate_cookbook_structure(cookbook_dir)
2242
+
2243
+ missing_items = [check for check, valid in validation.items() if not valid]
2244
+ if missing_items:
2245
+ context_parts.append(f"Missing: {', '.join(missing_items)}")
2246
+
2247
+ # Check if metadata parsing failed
2248
+ metadata_file = cookbook_dir / METADATA_FILENAME
2249
+ if metadata_file.exists():
2250
+ try:
2251
+ parse_cookbook_metadata(str(metadata_file))
2252
+ context_parts.append("metadata.rb exists and parses successfully")
2253
+ except Exception as e:
2254
+ context_parts.append(f"metadata.rb parsing error: {str(e)[:100]}")
2255
+
2256
+ # Check AI configuration if using AI
2257
+ ai_config = load_ai_settings()
2258
+ use_ai = (
2259
+ ai_config.get("provider")
2260
+ and ai_config.get("provider") != LOCAL_PROVIDER
2261
+ and ai_config.get("api_key")
2262
+ )
2263
+
2264
+ if use_ai:
2265
+ context_parts.append(
2266
+ f"Using AI analysis with {ai_config.get('provider', 'Unknown')}"
2267
+ )
2268
+ if not ai_config.get("api_key"):
2269
+ context_parts.append("AI configured but no API key provided")
2270
+ else:
2271
+ context_parts.append("Using rule-based analysis (AI not configured)")
2272
+
2273
+ return (
2274
+ "; ".join(context_parts) if context_parts else "No additional context available"
2275
+ )
2276
+
2277
+
2278
+ def _create_failed_analysis(cookbook_name, cookbook_dir, error_message):
2279
+ """Create analysis result for failed analysis."""
2280
+ # Add context to the error message
2281
+ context_info = _get_error_context(cookbook_dir)
2282
+ full_error = f"{error_message}\n\nContext: {context_info}"
327
2283
 
328
- def _create_failed_analysis(cookbook_name, cookbook_dir, error_message):
329
- """Create analysis result for failed analysis."""
330
2284
  return {
331
2285
  "name": cookbook_name,
332
2286
  "path": str(cookbook_dir),
333
2287
  "version": "Error",
334
2288
  "maintainer": "Error",
335
- "description": f"Analysis failed: {error_message}",
2289
+ "description": (
2290
+ f"Analysis failed: {error_message[:100]}"
2291
+ f"{'...' if len(error_message) > 100 else ''}"
2292
+ ),
336
2293
  "dependencies": 0,
337
2294
  "complexity": "Error",
338
2295
  "estimated_hours": 0,
339
- "recommendations": f"Error: {error_message}",
2296
+ "recommendations": full_error,
340
2297
  "status": ANALYSIS_STATUS_FAILED,
341
2298
  }
342
2299
 
@@ -347,13 +2304,620 @@ def _cleanup_progress_indicators(progress_bar, status_text):
347
2304
  status_text.empty()
348
2305
 
349
2306
 
2307
+ def analyse_project_cookbooks(cookbook_path: str, selected_cookbooks: list[str]):
2308
+ """Analyse cookbooks as a project with dependency analysis."""
2309
+ st.subheader("Project-Level Analysis Results")
2310
+
2311
+ progress_bar, status_text = _setup_analysis_progress()
2312
+ results = _perform_cookbook_analysis(
2313
+ cookbook_path, selected_cookbooks, progress_bar, status_text
2314
+ )
2315
+
2316
+ # Perform project-level dependency analysis
2317
+ status_text.text("Analyzing project dependencies...")
2318
+ project_analysis = _analyse_project_dependencies(
2319
+ cookbook_path, selected_cookbooks, results
2320
+ )
2321
+
2322
+ _cleanup_progress_indicators(progress_bar, status_text)
2323
+
2324
+ # Store results in session state
2325
+ st.session_state.analysis_results = results
2326
+ st.session_state.analysis_cookbook_path = cookbook_path
2327
+ st.session_state.total_cookbooks = len(selected_cookbooks)
2328
+ st.session_state.project_analysis = project_analysis
2329
+
2330
+ # Trigger rerun to display results
2331
+ st.rerun()
2332
+
2333
+
2334
+ def _analyse_project_dependencies(
2335
+ cookbook_path: str, selected_cookbooks: list[str], individual_results: list
2336
+ ) -> dict:
2337
+ """Analyze dependencies across all cookbooks in the project."""
2338
+ project_analysis = {
2339
+ "dependency_graph": {},
2340
+ "migration_order": [],
2341
+ "circular_dependencies": [],
2342
+ "project_complexity": "Low",
2343
+ "project_effort_days": 0,
2344
+ "migration_strategy": "phased",
2345
+ "risks": [],
2346
+ "recommendations": [],
2347
+ }
2348
+
2349
+ try:
2350
+ # Build dependency graph
2351
+ dependency_graph = _build_dependency_graph(cookbook_path, selected_cookbooks)
2352
+ project_analysis["dependency_graph"] = dependency_graph
2353
+
2354
+ # Determine migration order using topological sort
2355
+ migration_order = _calculate_migration_order(
2356
+ dependency_graph, individual_results
2357
+ )
2358
+ project_analysis["migration_order"] = migration_order
2359
+
2360
+ # Identify circular dependencies
2361
+ circular_deps = _find_circular_dependencies(dependency_graph)
2362
+ project_analysis["circular_dependencies"] = circular_deps
2363
+
2364
+ # Calculate project-level metrics
2365
+ project_metrics = _calculate_project_metrics(
2366
+ individual_results, dependency_graph
2367
+ )
2368
+ project_analysis.update(project_metrics)
2369
+
2370
+ # Generate project recommendations
2371
+ recommendations = _generate_project_recommendations(
2372
+ project_analysis, individual_results
2373
+ )
2374
+ project_analysis["recommendations"] = recommendations
2375
+
2376
+ except Exception as e:
2377
+ st.warning(f"Project dependency analysis failed: {e}")
2378
+ # Continue with basic analysis
2379
+
2380
+ return project_analysis
2381
+
2382
+
2383
+ def _build_dependency_graph(cookbook_path: str, selected_cookbooks: list[str]) -> dict:
2384
+ """Build a dependency graph for all cookbooks in the project."""
2385
+ dependency_graph = {}
2386
+
2387
+ for cookbook_name in selected_cookbooks:
2388
+ cookbook_dir = _find_cookbook_directory(cookbook_path, cookbook_name)
2389
+ if cookbook_dir:
2390
+ try:
2391
+ # Use the existing dependency analysis function
2392
+ dep_analysis = analyse_cookbook_dependencies(str(cookbook_dir))
2393
+ # Parse the markdown response to extract dependencies
2394
+ dependencies = _extract_dependencies_from_markdown(dep_analysis)
2395
+ dependency_graph[cookbook_name] = dependencies
2396
+ except Exception:
2397
+ # If dependency analysis fails, assume no dependencies
2398
+ dependency_graph[cookbook_name] = []
2399
+
2400
+ return dependency_graph
2401
+
2402
+
2403
+ def _extract_dependencies_from_markdown(markdown_text: str) -> list[str]:
2404
+ """Extract dependencies from markdown output of analyse_cookbook_dependencies."""
2405
+ dependencies = []
2406
+
2407
+ # Look for the dependency graph section
2408
+ lines = markdown_text.split("\n")
2409
+ in_graph_section = False
2410
+
2411
+ for line in lines:
2412
+ if "## Dependency Graph:" in line:
2413
+ in_graph_section = True
2414
+ elif in_graph_section and line.startswith("##"):
2415
+ break
2416
+ elif in_graph_section and "├──" in line:
2417
+ # Extract dependency name
2418
+ dep_line = line.strip()
2419
+ if "├──" in dep_line:
2420
+ dep_name = dep_line.split("├──")[-1].strip()
2421
+ if dep_name and dep_name != "External dependencies:":
2422
+ dependencies.append(dep_name)
2423
+
2424
+ return dependencies
2425
+
2426
+
2427
+ def _calculate_migration_order(
2428
+ dependency_graph: dict, individual_results: list
2429
+ ) -> list[dict]:
2430
+ """Calculate optimal migration order using topological sort."""
2431
+ order = _perform_topological_sort(dependency_graph)
2432
+
2433
+ # If topological sort failed due to cycles, fall back to complexity-based ordering
2434
+ if len(order) != len(dependency_graph):
2435
+ order = _fallback_migration_order(individual_results)
2436
+
2437
+ # Convert to detailed order with metadata
2438
+ return _build_detailed_migration_order(order, dependency_graph, individual_results)
2439
+
2440
+
2441
+ def _perform_topological_sort(dependency_graph: dict) -> list[str]:
2442
+ """Perform topological sort on dependency graph."""
2443
+ visited = set()
2444
+ temp_visited = set()
2445
+ order = []
2446
+
2447
+ def visit(cookbook_name: str) -> bool:
2448
+ if cookbook_name in temp_visited:
2449
+ return False # Circular dependency detected
2450
+ if cookbook_name in visited:
2451
+ return True
2452
+
2453
+ temp_visited.add(cookbook_name)
2454
+
2455
+ # Visit all dependencies first
2456
+ for dep in dependency_graph.get(cookbook_name, []):
2457
+ if dep in dependency_graph and not visit(dep):
2458
+ return False
2459
+
2460
+ temp_visited.remove(cookbook_name)
2461
+ visited.add(cookbook_name)
2462
+ order.append(cookbook_name)
2463
+ return True
2464
+
2465
+ # Visit all cookbooks
2466
+ for cookbook_name in dependency_graph:
2467
+ if cookbook_name not in visited and not visit(cookbook_name):
2468
+ break # Circular dependency detected
2469
+
2470
+ return order
2471
+
2472
+
2473
+ def _build_detailed_migration_order(
2474
+ order: list[str], dependency_graph: dict, individual_results: list
2475
+ ) -> list[dict]:
2476
+ """Build detailed migration order with metadata."""
2477
+ detailed_order = []
2478
+ for i, cookbook_name in enumerate(reversed(order), 1):
2479
+ cookbook_result = next(
2480
+ (r for r in individual_results if r["name"] == cookbook_name), None
2481
+ )
2482
+ if cookbook_result:
2483
+ detailed_order.append(
2484
+ {
2485
+ "phase": i,
2486
+ "cookbook": cookbook_name,
2487
+ "complexity": cookbook_result.get("complexity", "Unknown"),
2488
+ "effort_days": cookbook_result.get("estimated_hours", 0) / 8,
2489
+ "dependencies": dependency_graph.get(cookbook_name, []),
2490
+ "reason": _get_migration_reason(cookbook_name, dependency_graph, i),
2491
+ }
2492
+ )
2493
+
2494
+ return detailed_order
2495
+
2496
+
2497
+ def _fallback_migration_order(individual_results: list) -> list[str]:
2498
+ """Fallback migration order based on complexity (low to high)."""
2499
+ # Sort by complexity score (ascending) and then by dependencies (fewer first)
2500
+ sorted_results = sorted(
2501
+ individual_results,
2502
+ key=lambda x: (
2503
+ {"Low": 0, "Medium": 1, "High": 2}.get(x.get("complexity", "Medium"), 1),
2504
+ x.get("dependencies", 0),
2505
+ ),
2506
+ )
2507
+ return [r["name"] for r in sorted_results]
2508
+
2509
+
2510
+ def _get_migration_reason(
2511
+ cookbook_name: str, dependency_graph: dict, phase: int
2512
+ ) -> str:
2513
+ """Get the reason for migrating a cookbook at this phase."""
2514
+ dependencies = dependency_graph.get(cookbook_name, [])
2515
+
2516
+ if not dependencies:
2517
+ return "No dependencies - can be migrated early"
2518
+ elif phase == 1:
2519
+ return "Foundation cookbook with minimal dependencies"
2520
+ else:
2521
+ dep_names = ", ".join(dependencies[:3]) # Show first 3 dependencies
2522
+ if len(dependencies) > 3:
2523
+ dep_names += f" and {len(dependencies) - 3} more"
2524
+ return f"Depends on: {dep_names}"
2525
+
2526
+
2527
+ def _detect_cycle_dependency(
2528
+ dependency_graph: dict, start: str, current: str, path: list[str]
2529
+ ) -> list[str] | None:
2530
+ """Detect a cycle in the dependency graph starting from current node."""
2531
+ if current in path:
2532
+ # Found a cycle
2533
+ cycle_start = path.index(current)
2534
+ return path[cycle_start:] + [current]
2535
+
2536
+ path.append(current)
2537
+
2538
+ for dep in dependency_graph.get(current, []):
2539
+ if dep in dependency_graph: # Only check cookbooks in our project
2540
+ cycle = _detect_cycle_dependency(dependency_graph, start, dep, path)
2541
+ if cycle:
2542
+ return cycle
2543
+
2544
+ path.pop()
2545
+ return None
2546
+
2547
+
2548
+ def _find_circular_dependencies(dependency_graph: dict) -> list[dict]:
2549
+ """Find circular dependencies in the dependency graph."""
2550
+ circular_deps = []
2551
+ visited = set()
2552
+
2553
+ for cookbook in dependency_graph:
2554
+ if cookbook not in visited:
2555
+ cycle = _detect_cycle_dependency(dependency_graph, cookbook, cookbook, [])
2556
+ if cycle:
2557
+ circular_deps.append(
2558
+ {
2559
+ "cookbooks": cycle,
2560
+ "type": "circular_dependency",
2561
+ "severity": "high",
2562
+ }
2563
+ )
2564
+ # Mark all cycle members as visited to avoid duplicate detection
2565
+ visited.update(cycle)
2566
+
2567
+ return circular_deps
2568
+
2569
+
2570
+ def _calculate_project_metrics(
2571
+ individual_results: list, dependency_graph: dict
2572
+ ) -> dict:
2573
+ """Calculate project-level complexity and effort metrics."""
2574
+ total_effort = sum(
2575
+ r.get("estimated_hours", 0) / 8 for r in individual_results
2576
+ ) # Convert hours to days
2577
+ avg_complexity = (
2578
+ sum(
2579
+ {"Low": 30, "Medium": 50, "High": 80}.get(r.get("complexity", "Medium"), 50)
2580
+ for r in individual_results
2581
+ )
2582
+ / len(individual_results)
2583
+ if individual_results
2584
+ else 50
2585
+ )
2586
+
2587
+ # Determine project complexity
2588
+ if avg_complexity > 70:
2589
+ project_complexity = "High"
2590
+ elif avg_complexity > 40:
2591
+ project_complexity = "Medium"
2592
+ else:
2593
+ project_complexity = "Low"
2594
+
2595
+ # Determine migration strategy based on dependencies and complexity
2596
+ total_dependencies = sum(len(deps) for deps in dependency_graph.values())
2597
+ has_circular_deps = any(
2598
+ len(dependency_graph.get(cb, [])) > 0 for cb in dependency_graph
2599
+ )
2600
+
2601
+ if project_complexity == "High" or total_dependencies > len(individual_results) * 2:
2602
+ migration_strategy = "phased"
2603
+ elif has_circular_deps:
2604
+ migration_strategy = "parallel"
2605
+ else:
2606
+ migration_strategy = "big_bang"
2607
+
2608
+ # Calculate parallel tracks if needed
2609
+ parallel_tracks = 1
2610
+ if migration_strategy == "parallel":
2611
+ parallel_tracks = min(3, max(2, len(individual_results) // 5))
2612
+
2613
+ # Calculate calendar timeline based on strategy
2614
+ # This applies strategy multipliers (phased +10%, big_bang -10%, parallel +5%)
2615
+ timeline_weeks = get_timeline_weeks(total_effort, strategy=migration_strategy)
2616
+
2617
+ return {
2618
+ "project_complexity": project_complexity,
2619
+ "project_effort_days": round(total_effort, 1),
2620
+ "project_timeline_weeks": timeline_weeks,
2621
+ "migration_strategy": migration_strategy,
2622
+ "parallel_tracks": parallel_tracks,
2623
+ "total_dependencies": total_dependencies,
2624
+ "dependency_density": round(total_dependencies / len(individual_results), 2)
2625
+ if individual_results
2626
+ else 0,
2627
+ }
2628
+
2629
+
2630
+ def _generate_project_recommendations(
2631
+ project_analysis: dict, individual_results: list
2632
+ ) -> list[str]:
2633
+ """Generate project-level recommendations."""
2634
+ recommendations = []
2635
+
2636
+ strategy = project_analysis.get("migration_strategy", "phased")
2637
+ complexity = project_analysis.get("project_complexity", "Medium")
2638
+ effort_days = project_analysis.get("project_effort_days", 0)
2639
+ circular_deps = project_analysis.get("circular_dependencies", [])
2640
+
2641
+ # Strategy recommendations
2642
+ if strategy == "phased":
2643
+ recommendations.append(
2644
+ "• Use phased migration approach due to project complexity and dependencies"
2645
+ )
2646
+ recommendations.append(
2647
+ "• Start with foundation cookbooks (minimal dependencies) in Phase 1"
2648
+ )
2649
+ recommendations.append("• Migrate dependent cookbooks in subsequent phases")
2650
+ elif strategy == "parallel":
2651
+ tracks = project_analysis.get("parallel_tracks", 2)
2652
+ recommendations.append(
2653
+ f"• Use parallel migration with {tracks} tracks to handle complexity"
2654
+ )
2655
+ recommendations.append("• Assign dedicated teams to each migration track")
2656
+ else:
2657
+ recommendations.append("• Big-bang migration suitable for this project scope")
2658
+
2659
+ # Complexity-based recommendations
2660
+ if complexity == "High":
2661
+ recommendations.append(
2662
+ "• Allocate senior Ansible engineers for complex cookbook conversions"
2663
+ )
2664
+ recommendations.append("• Plan for extensive testing and validation phases")
2665
+ elif complexity == "Medium":
2666
+ recommendations.append(
2667
+ "• Standard engineering team with Ansible experience sufficient"
2668
+ )
2669
+ recommendations.append("• Include peer reviews for quality assurance")
2670
+
2671
+ # Effort-based recommendations
2672
+ if effort_days > 30:
2673
+ recommendations.append("• Consider extending timeline to reduce team pressure")
2674
+ recommendations.append(
2675
+ "• Break migration into 2-week sprints with deliverables"
2676
+ )
2677
+ else:
2678
+ recommendations.append("• Timeline suitable for focused migration effort")
2679
+
2680
+ # Dependency recommendations
2681
+ dependency_density = project_analysis.get("dependency_density", 0)
2682
+ if dependency_density > 2:
2683
+ recommendations.append(
2684
+ "• High dependency density - prioritize dependency resolution"
2685
+ )
2686
+ recommendations.append("• Create shared Ansible roles for common dependencies")
2687
+
2688
+ # Circular dependency warnings
2689
+ if circular_deps:
2690
+ recommendations.append(
2691
+ f"• {len(circular_deps)} circular dependency groups detected"
2692
+ )
2693
+ recommendations.append(
2694
+ "• Resolve circular dependencies before migration begins"
2695
+ )
2696
+ recommendations.append("• Consider refactoring interdependent cookbooks")
2697
+
2698
+ # Team and resource recommendations
2699
+ total_cookbooks = len(individual_results)
2700
+ if total_cookbooks > 10:
2701
+ recommendations.append(
2702
+ "• Large project scope - consider dedicated migration team"
2703
+ )
2704
+ else:
2705
+ recommendations.append("• Project size manageable with existing team capacity")
2706
+
2707
+ return recommendations
2708
+
2709
+
350
2710
  def _display_analysis_results(results, total_cookbooks):
351
- """Display the analysis results."""
352
- if results:
353
- _display_analysis_summary(results, total_cookbooks)
354
- _display_results_table(results)
355
- _display_detailed_analysis(results)
356
- _display_download_option(results)
2711
+ """Display the complete analysis results."""
2712
+ # Display stored analysis info messages if available
2713
+ if "analysis_info_messages" in st.session_state:
2714
+ for message in st.session_state.analysis_info_messages:
2715
+ st.info(message)
2716
+ st.success(
2717
+ f"✓ Analysis completed! Analysed {len(results)} cookbook(s) with "
2718
+ f"detailed AI insights."
2719
+ )
2720
+
2721
+ # Add a back button to return to analysis selection
2722
+ col1, _ = st.columns([1, 4])
2723
+ with col1:
2724
+ if st.button(
2725
+ "Analyse More Cookbooks",
2726
+ help="Return to cookbook selection",
2727
+ key="analyse_more",
2728
+ ):
2729
+ # Clear session state to go back to selection
2730
+ st.session_state.analysis_results = None
2731
+ st.session_state.analysis_cookbook_path = None
2732
+ st.session_state.total_cookbooks = 0
2733
+ st.session_state.project_analysis = None
2734
+ # Clean up temporary directory when going back
2735
+ if st.session_state.temp_dir and st.session_state.temp_dir.exists():
2736
+ shutil.rmtree(st.session_state.temp_dir, ignore_errors=True)
2737
+ st.session_state.temp_dir = None
2738
+ st.rerun()
2739
+
2740
+ st.subheader("Analysis Results")
2741
+
2742
+ _display_analysis_summary(results, total_cookbooks)
2743
+
2744
+ # Display project-level analysis if available
2745
+ if "project_analysis" in st.session_state and st.session_state.project_analysis:
2746
+ _display_project_analysis(st.session_state.project_analysis)
2747
+
2748
+ _display_results_table(results)
2749
+ _display_detailed_analysis(results)
2750
+ _display_download_option(results)
2751
+
2752
+
2753
+ def _display_project_analysis(project_analysis: dict):
2754
+ """Display project-level analysis results."""
2755
+ st.subheader("Project-Level Analysis")
2756
+
2757
+ # Project metrics
2758
+ col1, col2, col3, col4 = st.columns(4)
2759
+
2760
+ with col1:
2761
+ st.metric(
2762
+ "Project Complexity", project_analysis.get("project_complexity", "Unknown")
2763
+ )
2764
+
2765
+ with col2:
2766
+ effort_days = project_analysis.get("project_effort_days", 0)
2767
+ timeline_weeks = project_analysis.get("project_timeline_weeks", 2)
2768
+ effort_hours = effort_days * 8
2769
+ st.metric(
2770
+ "Total Effort",
2771
+ f"{effort_hours:.0f} hours ({timeline_weeks} weeks calendar time)",
2772
+ )
2773
+
2774
+ with col3:
2775
+ strategy = (
2776
+ project_analysis.get("migration_strategy", "phased")
2777
+ .replace("_", " ")
2778
+ .title()
2779
+ )
2780
+ st.metric("Migration Strategy", strategy)
2781
+
2782
+ with col4:
2783
+ dependencies = project_analysis.get("total_dependencies", 0)
2784
+ st.metric("Total Dependencies", dependencies)
2785
+
2786
+ # Migration order
2787
+ if project_analysis.get("migration_order"):
2788
+ st.subheader("Recommended Migration Order")
2789
+
2790
+ migration_df = pd.DataFrame(project_analysis["migration_order"])
2791
+ migration_df = migration_df.rename(
2792
+ columns={
2793
+ "phase": "Phase",
2794
+ "cookbook": "Cookbook",
2795
+ "complexity": "Complexity",
2796
+ "effort_days": "Effort (Days)",
2797
+ "dependencies": "Dependencies",
2798
+ "reason": "Migration Reason",
2799
+ }
2800
+ )
2801
+
2802
+ st.dataframe(migration_df, width="stretch")
2803
+
2804
+ # Dependency graph visualization
2805
+ if project_analysis.get("dependency_graph"):
2806
+ with st.expander("Dependency Graph"):
2807
+ _display_dependency_graph(project_analysis["dependency_graph"])
2808
+
2809
+ # Circular dependencies warning
2810
+ if project_analysis.get("circular_dependencies"):
2811
+ st.warning("Circular Dependencies Detected")
2812
+ for circ in project_analysis["circular_dependencies"]:
2813
+ cookbooks = " → ".join(circ["cookbooks"])
2814
+ st.write(f"**Cycle:** {cookbooks}")
2815
+
2816
+ # Effort explanation
2817
+ with st.expander("Effort vs Timeline"):
2818
+ effort_days = project_analysis.get("project_effort_days", 0)
2819
+ effort_hours = effort_days * 8
2820
+ timeline_weeks = project_analysis.get("project_timeline_weeks", 2)
2821
+ strategy = (
2822
+ project_analysis.get("migration_strategy", "phased")
2823
+ .replace("_", " ")
2824
+ .title()
2825
+ )
2826
+ explanation = (
2827
+ f"**Effort**: {effort_hours:.0f} hours ({effort_days:.1f} person-days) "
2828
+ f"of actual work\n\n"
2829
+ f"**Calendar Timeline**: {timeline_weeks} weeks\n\n"
2830
+ f"**Strategy**: {strategy}\n\n"
2831
+ f"The difference between effort and timeline accounts for:\n"
2832
+ f"• Phased approach adds ~10% overhead for testing between phases\n"
2833
+ f"• Parallel execution allows some tasks to overlap\n"
2834
+ f"• Dependency constraints may extend the critical path\n"
2835
+ f"• Team coordination and integration time"
2836
+ )
2837
+ st.write(explanation)
2838
+
2839
+ # Project recommendations
2840
+ if project_analysis.get("recommendations"):
2841
+ with st.expander("Project Recommendations"):
2842
+ for rec in project_analysis["recommendations"]:
2843
+ st.write(rec)
2844
+
2845
+
2846
+ def _display_dependency_graph(dependency_graph: dict):
2847
+ """Display a visual representation of the dependency graph."""
2848
+ st.write("**Cookbook Dependencies:**")
2849
+
2850
+ for cookbook, deps in dependency_graph.items():
2851
+ if deps:
2852
+ deps_str = ", ".join(deps)
2853
+ st.write(f"• **{cookbook}** depends on: {deps_str}")
2854
+ else:
2855
+ st.write(f"• **{cookbook}** (no dependencies)")
2856
+
2857
+ # Show dependency statistics
2858
+ total_deps = sum(len(deps) for deps in dependency_graph.values())
2859
+ cookbooks_with_deps = sum(1 for deps in dependency_graph.values() if deps)
2860
+ isolated_cookbooks = len(dependency_graph) - cookbooks_with_deps
2861
+
2862
+ st.write(f"""
2863
+ **Dependency Statistics:**
2864
+ - Total dependencies: {total_deps}
2865
+ - Cookbooks with dependencies: {cookbooks_with_deps}
2866
+ - Independent cookbooks: {isolated_cookbooks}
2867
+ - Average dependencies per cookbook: {total_deps / len(dependency_graph):.1f}
2868
+ """)
2869
+
2870
+
2871
+ def _display_download_option(results):
2872
+ """Display download options for analysis results."""
2873
+ st.subheader("Download Options")
2874
+
2875
+ successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
2876
+
2877
+ if not successful_results:
2878
+ st.info("No successfully analysed cookbooks available for download.")
2879
+
2880
+ return
2881
+
2882
+ col1, _col2 = st.columns(2)
2883
+
2884
+ with col1:
2885
+ # Download analysis report
2886
+ analysis_data = _create_analysis_report(results)
2887
+ st.download_button(
2888
+ label="Download Analysis Report",
2889
+ data=analysis_data,
2890
+ file_name="cookbook_analysis_report.json",
2891
+ mime="application/json",
2892
+ help="Download detailed analysis results as JSON",
2893
+ )
2894
+
2895
+ # Convert to Ansible Playbooks button - moved outside columns for better reliability
2896
+ if st.button(
2897
+ "Convert to Ansible Playbooks",
2898
+ type="primary",
2899
+ help="Convert analysed cookbooks to Ansible playbooks and download as ZIP",
2900
+ key="convert_to_ansible_playbooks",
2901
+ ):
2902
+ # Check AI configuration status
2903
+ ai_config = load_ai_settings()
2904
+ ai_available = (
2905
+ ai_config.get("provider")
2906
+ and ai_config.get("provider") != LOCAL_PROVIDER
2907
+ and ai_config.get("api_key")
2908
+ )
2909
+
2910
+ if ai_available:
2911
+ provider = ai_config.get("provider", "Unknown")
2912
+ model = ai_config.get("model", "Unknown")
2913
+ st.info(f"Using AI-enhanced conversion with {provider} ({model})")
2914
+ else:
2915
+ st.info(
2916
+ "Using deterministic conversion. Configure AI settings "
2917
+ "for enhanced results."
2918
+ )
2919
+
2920
+ _convert_and_download_playbooks(results)
357
2921
 
358
2922
 
359
2923
  def _display_analysis_summary(results, total_cookbooks):
@@ -362,9 +2926,9 @@ def _display_analysis_summary(results, total_cookbooks):
362
2926
 
363
2927
  with col1:
364
2928
  successful = len(
365
- [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYZED]
2929
+ [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
366
2930
  )
367
- st.metric("Successfully Analyzed", f"{successful}/{total_cookbooks}")
2931
+ st.metric("Successfully Analysed", f"{successful}/{total_cookbooks}")
368
2932
 
369
2933
  with col2:
370
2934
  total_hours = sum(r.get("estimated_hours", 0) for r in results)
@@ -379,47 +2943,587 @@ def _display_analysis_summary(results, total_cookbooks):
379
2943
  def _display_results_table(results):
380
2944
  """Display results in a table format."""
381
2945
  df = pd.DataFrame(results)
382
- st.dataframe(df, use_container_width=True)
2946
+ st.dataframe(df, width="stretch")
383
2947
 
384
2948
 
385
2949
  def _display_detailed_analysis(results):
386
2950
  """Display detailed analysis for each cookbook."""
387
2951
  st.subheader("Detailed Analysis")
388
2952
 
389
- for result in results:
390
- if result["status"] == ANALYSIS_STATUS_ANALYZED:
2953
+ successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
2954
+ failed_results = [r for r in results if r["status"] == ANALYSIS_STATUS_FAILED]
2955
+
2956
+ if successful_results:
2957
+ st.markdown("### Successfully Analysed Cookbooks")
2958
+ for result in successful_results:
391
2959
  _display_single_cookbook_details(result)
392
2960
 
2961
+ if failed_results:
2962
+ st.markdown("### Failed Analysis Cookbooks")
2963
+ for result in failed_results:
2964
+ _display_failed_cookbook_details(result)
393
2965
 
394
- def _display_single_cookbook_details(result):
395
- """Display detailed analysis for a single cookbook."""
396
- with st.expander(f"{result['name']} - {result['complexity']} Complexity"):
397
- col1, col2 = st.columns(2)
398
2966
 
2967
+ def _validate_cookbook_structure(cookbook_dir: Path) -> dict:
2968
+ """Validate the basic structure of a cookbook for analysis."""
2969
+ validation = {}
2970
+
2971
+ # Check if directory exists
2972
+ validation["Cookbook directory exists"] = (
2973
+ cookbook_dir.exists() and cookbook_dir.is_dir()
2974
+ )
2975
+
2976
+ if not validation["Cookbook directory exists"]:
2977
+ return validation
2978
+
2979
+ # Check metadata.rb
2980
+ metadata_file = cookbook_dir / METADATA_FILENAME
2981
+ validation["metadata.rb exists"] = metadata_file.exists()
2982
+
2983
+ # Check recipes directory
2984
+ recipes_dir = cookbook_dir / "recipes"
2985
+ validation["recipes/ directory exists"] = (
2986
+ recipes_dir.exists() and recipes_dir.is_dir()
2987
+ )
2988
+
2989
+ if validation["recipes/ directory exists"]:
2990
+ recipe_files = list(recipes_dir.glob("*.rb"))
2991
+ validation["Has recipe files"] = len(recipe_files) > 0
2992
+ validation["Has default.rb recipe"] = (recipes_dir / "default.rb").exists()
2993
+ else:
2994
+ validation["Has recipe files"] = False
2995
+ validation["Has default.rb recipe"] = False
2996
+
2997
+ # Check for common cookbook directories
2998
+ common_dirs = ["attributes", "templates", "files", "libraries", "definitions"]
2999
+ for dir_name in common_dirs:
3000
+ dir_path = cookbook_dir / dir_name
3001
+ validation[f"{dir_name}/ directory exists"] = (
3002
+ dir_path.exists() and dir_path.is_dir()
3003
+ )
3004
+
3005
+ return validation
3006
+
3007
+
3008
+ def _display_single_cookbook_details(result):
3009
+ """Display detailed information for a successfully analysed cookbook."""
3010
+ with st.expander(f"{result['name']} - Analysis Complete", expanded=True):
3011
+ # Basic information
3012
+ col1, col2, col3 = st.columns(3)
399
3013
  with col1:
400
- st.write(f"**Version:** {result['version']}")
401
- st.write(f"**Maintainer:** {result['maintainer']}")
402
- st.write(f"**Dependencies:** {result['dependencies']}")
3014
+ st.metric("Version", result.get("version", "Unknown"))
3015
+ with col2:
3016
+ st.metric("Maintainer", result.get("maintainer", "Unknown"))
3017
+ with col3:
3018
+ st.metric("Dependencies", result.get("dependencies", 0))
403
3019
 
3020
+ # Complexity and effort
3021
+ col1, col2 = st.columns(2)
3022
+ with col1:
3023
+ complexity = result.get("complexity", "Unknown")
3024
+ if complexity == "High":
3025
+ st.metric("Complexity", complexity, delta="High")
3026
+ elif complexity == "Medium":
3027
+ st.metric("Complexity", complexity, delta="Medium")
3028
+ else:
3029
+ st.metric("Complexity", complexity, delta="Low")
404
3030
  with col2:
405
- st.write(f"**Estimated Hours:** {result['estimated_hours']:.1f}")
406
- st.write(f"**Complexity:** {result['complexity']}")
3031
+ hours = result.get("estimated_hours", 0)
3032
+ st.metric("Estimated Hours", f"{hours:.1f}")
407
3033
 
408
- st.write(f"**Recommendations:** {result['recommendations']}")
3034
+ # Path
3035
+ st.write(f"**Cookbook Path:** {result['path']}")
409
3036
 
3037
+ # Recommendations
3038
+ if result.get("recommendations"):
3039
+ st.markdown("**Analysis Recommendations:**")
3040
+ st.info(result["recommendations"])
410
3041
 
411
- def _display_download_option(results):
412
- """Display download option for analysis results."""
413
- successful = len([r for r in results if r["status"] == ANALYSIS_STATUS_ANALYZED])
414
- if successful > 0:
415
- st.download_button(
416
- label="Download Analysis Report",
417
- data=pd.DataFrame(results).to_json(indent=2),
418
- file_name="cookbook_analysis.json",
419
- mime="application/json",
420
- help="Download the analysis results as JSON",
3042
+
3043
+ def _display_failed_cookbook_details(result):
3044
+ """Display detailed failure information for a cookbook."""
3045
+ with st.expander(f"{result['name']} - Analysis Failed", expanded=True):
3046
+ st.error(f"**Analysis Error:** {result['recommendations']}")
3047
+
3048
+ # Show cookbook path
3049
+ st.write(f"**Cookbook Path:** {result['path']}")
3050
+
3051
+ # Try to show some basic validation info
3052
+ cookbook_dir = Path(result["path"])
3053
+ validation_info = _validate_cookbook_structure(cookbook_dir)
3054
+
3055
+ if validation_info:
3056
+ st.markdown("**Cookbook Structure Validation:**")
3057
+ for check, status in validation_info.items():
3058
+ icon = "✓" if status else "✗"
3059
+ st.write(f"{icon} {check}")
3060
+
3061
+ # Suggest fixes
3062
+ st.markdown("**Suggested Fixes:**")
3063
+ st.markdown("""
3064
+ - Check if `metadata.rb` exists and is valid Ruby syntax
3065
+ - Ensure `recipes/` directory exists with at least one `.rb` file
3066
+ - Verify cookbook dependencies are properly declared
3067
+ - Check for syntax errors in recipe files
3068
+ - Ensure the cookbook follows standard Chef structure
3069
+ """)
3070
+
3071
+ # Show raw error details in a collapsible section
3072
+ with st.expander("Technical Error Details"):
3073
+ st.code(result["recommendations"], language="text")
3074
+
3075
+
3076
+ def _convert_and_download_playbooks(results):
3077
+ """Convert analysed cookbooks to Ansible playbooks and provide download."""
3078
+ successful_results = [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
3079
+
3080
+ if not successful_results:
3081
+ st.warning("No successfully analysed cookbooks to convert.")
3082
+ return
3083
+
3084
+ # Get project recommendations from session state
3085
+ project_recommendations = None
3086
+ if "project_analysis" in st.session_state and st.session_state.project_analysis:
3087
+ project_recommendations = st.session_state.project_analysis
3088
+
3089
+ with st.spinner("Converting cookbooks to Ansible playbooks..."):
3090
+ playbooks = []
3091
+ templates = []
3092
+
3093
+ for result in successful_results:
3094
+ # _convert_single_cookbook now returns tuple of (playbooks, templates)
3095
+ cookbook_playbooks, cookbook_templates = _convert_single_cookbook(
3096
+ result, project_recommendations
3097
+ )
3098
+ if cookbook_playbooks:
3099
+ playbooks.extend(cookbook_playbooks)
3100
+ if cookbook_templates:
3101
+ templates.extend(cookbook_templates)
3102
+
3103
+ st.info(
3104
+ f"Total: {len(playbooks)} playbook(s) and {len(templates)} "
3105
+ f"template(s) ready for download"
3106
+ )
3107
+
3108
+ if playbooks:
3109
+ # Save converted playbooks to temporary directory for validation
3110
+ try:
3111
+ output_dir = Path(tempfile.mkdtemp(prefix="souschef_converted_"))
3112
+ with contextlib.suppress(FileNotFoundError, OSError):
3113
+ output_dir.chmod(0o700) # Secure permissions: rwx------
3114
+ for _i, playbook in enumerate(playbooks):
3115
+ # Sanitize filename - include recipe name to avoid conflicts
3116
+ recipe_name = playbook["recipe_file"].replace(".rb", "")
3117
+ cookbook_name = _sanitize_filename(playbook["cookbook_name"])
3118
+ recipe_name = _sanitize_filename(recipe_name)
3119
+ filename = f"{cookbook_name}_{recipe_name}.yml"
3120
+ (output_dir / filename).write_text(playbook["playbook_content"])
3121
+
3122
+ # Store path in session state for validation page
3123
+ st.session_state.converted_playbooks_path = str(output_dir)
3124
+ st.success("Playbooks converted and staged for validation.")
3125
+ except Exception as e:
3126
+ st.warning(f"Could not stage playbooks for validation: {e}")
3127
+
3128
+ _handle_playbook_download(playbooks, templates)
3129
+
3130
+
3131
+ def _convert_single_cookbook(
3132
+ result: dict, project_recommendations: dict | None = None
3133
+ ) -> tuple[list, list]:
3134
+ """
3135
+ Convert entire cookbook (all recipes) to Ansible playbooks.
3136
+
3137
+ Args:
3138
+ result: Cookbook analysis result.
3139
+ project_recommendations: Optional project recommendations.
3140
+
3141
+ Returns:
3142
+ Tuple of (playbooks list, templates list).
3143
+
3144
+ """
3145
+ cookbook_dir = Path(result["path"])
3146
+ recipes_dir = cookbook_dir / "recipes"
3147
+
3148
+ if not recipes_dir.exists():
3149
+ st.warning(f"No recipes directory found in {result['name']}")
3150
+ return [], []
3151
+
3152
+ recipe_files = list(recipes_dir.glob("*.rb"))
3153
+ if not recipe_files:
3154
+ st.warning(f"No recipe files found in {result['name']}")
3155
+ return [], []
3156
+
3157
+ # Convert recipes
3158
+ converted_playbooks = _convert_recipes(
3159
+ result["name"], recipe_files, project_recommendations
3160
+ )
3161
+
3162
+ # Convert templates
3163
+ converted_templates = _convert_templates(result["name"], cookbook_dir)
3164
+
3165
+ return converted_playbooks, converted_templates
3166
+
3167
+
3168
+ def _convert_recipes(
3169
+ cookbook_name: str, recipe_files: list, project_recommendations: dict | None
3170
+ ) -> list:
3171
+ """
3172
+ Convert all recipes in a cookbook.
3173
+
3174
+ Args:
3175
+ cookbook_name: Name of the cookbook.
3176
+ recipe_files: List of recipe file paths.
3177
+ project_recommendations: Optional project recommendations.
3178
+
3179
+ Returns:
3180
+ List of converted playbooks.
3181
+
3182
+ """
3183
+ ai_config = load_ai_settings()
3184
+ provider_name = _get_ai_provider(ai_config)
3185
+ use_ai = (
3186
+ provider_name and provider_name != LOCAL_PROVIDER and ai_config.get("api_key")
3187
+ )
3188
+
3189
+ provider_mapping = {
3190
+ ANTHROPIC_CLAUDE_DISPLAY: "anthropic",
3191
+ ANTHROPIC_PROVIDER: "anthropic",
3192
+ "OpenAI": "openai",
3193
+ OPENAI_PROVIDER: "openai",
3194
+ IBM_WATSONX: "watson",
3195
+ RED_HAT_LIGHTSPEED: "lightspeed",
3196
+ }
3197
+ ai_provider = provider_mapping.get(
3198
+ provider_name,
3199
+ provider_name.lower().replace(" ", "_") if provider_name else "anthropic",
3200
+ )
3201
+
3202
+ converted_playbooks = []
3203
+ api_key = _get_ai_string_value(ai_config, "api_key", "")
3204
+ model = _get_ai_string_value(ai_config, "model", "claude-3-5-sonnet-20241022")
3205
+ temperature = _get_ai_float_value(ai_config, "temperature", 0.7)
3206
+ max_tokens = _get_ai_int_value(ai_config, "max_tokens", 4000)
3207
+ project_id = _get_ai_string_value(ai_config, "project_id", "")
3208
+ base_url = _get_ai_string_value(ai_config, "base_url", "")
3209
+
3210
+ for recipe_file in recipe_files:
3211
+ try:
3212
+ if use_ai:
3213
+ playbook_content = generate_playbook_from_recipe_with_ai(
3214
+ str(recipe_file),
3215
+ ai_provider=ai_provider,
3216
+ api_key=api_key,
3217
+ model=model,
3218
+ temperature=temperature,
3219
+ max_tokens=max_tokens,
3220
+ project_id=project_id,
3221
+ base_url=base_url,
3222
+ project_recommendations=project_recommendations,
3223
+ )
3224
+ else:
3225
+ playbook_content = generate_playbook_from_recipe(str(recipe_file))
3226
+
3227
+ if not playbook_content.startswith("Error"):
3228
+ converted_playbooks.append(
3229
+ {
3230
+ "cookbook_name": cookbook_name,
3231
+ "playbook_content": playbook_content,
3232
+ "recipe_file": recipe_file.name,
3233
+ "conversion_method": "AI-enhanced"
3234
+ if use_ai
3235
+ else "Deterministic",
3236
+ }
3237
+ )
3238
+ else:
3239
+ st.warning(f"Failed to convert {recipe_file.name}: {playbook_content}")
3240
+ except Exception as e:
3241
+ st.warning(f"Failed to convert {recipe_file.name}: {e}")
3242
+
3243
+ return converted_playbooks
3244
+
3245
+
3246
+ def _convert_templates(cookbook_name: str, cookbook_dir: Path) -> list:
3247
+ """
3248
+ Convert all templates in a cookbook.
3249
+
3250
+ Args:
3251
+ cookbook_name: Name of the cookbook.
3252
+ cookbook_dir: Path to cookbook directory.
3253
+
3254
+ Returns:
3255
+ List of converted templates.
3256
+
3257
+ """
3258
+ converted_templates = []
3259
+ template_results = convert_cookbook_templates(str(cookbook_dir))
3260
+
3261
+ if template_results.get("success"):
3262
+ for template_result in template_results.get("results", []):
3263
+ if template_result["success"]:
3264
+ converted_templates.append(
3265
+ {
3266
+ "cookbook_name": cookbook_name,
3267
+ "template_content": template_result["jinja2_content"],
3268
+ "template_file": Path(template_result["jinja2_file"]).name,
3269
+ "original_file": Path(template_result["original_file"]).name,
3270
+ "variables": template_result["variables"],
3271
+ }
3272
+ )
3273
+ if converted_templates:
3274
+ st.info(
3275
+ f"Converted {len(converted_templates)} template(s) from {cookbook_name}"
3276
+ )
3277
+ elif not template_results.get("message"):
3278
+ st.warning(
3279
+ f"Template conversion failed for {cookbook_name}: "
3280
+ f"{template_results.get('error', 'Unknown error')}"
3281
+ )
3282
+
3283
+ return converted_templates
3284
+
3285
+
3286
+ def _find_recipe_file(cookbook_dir: Path, cookbook_name: str) -> Path | None:
3287
+ """Find the appropriate recipe file for a cookbook."""
3288
+ recipes_dir = cookbook_dir / "recipes"
3289
+ if not recipes_dir.exists():
3290
+ st.warning(f"No recipes directory found in {cookbook_name}")
3291
+ return None
3292
+
3293
+ recipe_files = list(recipes_dir.glob("*.rb"))
3294
+ if not recipe_files:
3295
+ st.warning(f"No recipe files found in {cookbook_name}")
3296
+ return None
3297
+
3298
+ # Use the default.rb recipe if available, otherwise first recipe
3299
+ default_recipe = recipes_dir / "default.rb"
3300
+ return default_recipe if default_recipe.exists() else recipe_files[0]
3301
+
3302
+
3303
+ def _handle_playbook_download(playbooks: list, templates: list | None = None) -> None:
3304
+ """Handle the download of generated playbooks."""
3305
+ if not playbooks:
3306
+ st.error("No playbooks were successfully generated.")
3307
+ return
3308
+
3309
+ templates = templates or []
3310
+ playbook_archive = _create_playbook_archive(playbooks, templates)
3311
+
3312
+ # Display success and statistics
3313
+ unique_cookbooks = len({p["cookbook_name"] for p in playbooks})
3314
+ template_count = len(templates)
3315
+ st.success(
3316
+ f"Successfully converted {unique_cookbooks} cookbook(s) with "
3317
+ f"{len(playbooks)} recipe(s) and {template_count} template(s) to Ansible!"
3318
+ )
3319
+
3320
+ # Show summary
3321
+ _display_playbook_summary(len(playbooks), template_count)
3322
+
3323
+ # Provide download button
3324
+ _display_download_button(len(playbooks), template_count, playbook_archive)
3325
+
3326
+ # Show previews
3327
+ _display_playbook_previews(playbooks)
3328
+ _display_template_previews(templates)
3329
+
3330
+
3331
+ def _display_playbook_summary(playbook_count: int, template_count: int) -> None:
3332
+ """Display summary of archive contents."""
3333
+ if template_count > 0:
3334
+ st.info(
3335
+ f"Archive includes:\n"
3336
+ f"- {playbook_count} playbook files (.yml)\n"
3337
+ f"- {template_count} template files (.j2)\n"
3338
+ f"- README.md with conversion details"
3339
+ )
3340
+ else:
3341
+ st.info(
3342
+ f"Archive includes:\n"
3343
+ f"- {playbook_count} playbook files (.yml)\n"
3344
+ f"- README.md with conversion details\n"
3345
+ f"- Note: No templates were found in the converted cookbooks"
421
3346
  )
422
3347
 
423
3348
 
424
- if __name__ == "__main__":
425
- show_cookbook_analysis_page()
3349
+ def _display_download_button(
3350
+ playbook_count: int, template_count: int, archive_data: bytes
3351
+ ) -> None:
3352
+ """Display the download button for the archive."""
3353
+ download_label = f"Download Ansible Playbooks ({playbook_count} playbooks"
3354
+ if template_count > 0:
3355
+ download_label += f", {template_count} templates"
3356
+ download_label += ")"
3357
+
3358
+ st.download_button(
3359
+ label=download_label,
3360
+ data=archive_data,
3361
+ file_name="ansible_playbooks.zip",
3362
+ mime="application/zip",
3363
+ help=f"Download ZIP archive containing {playbook_count} playbooks "
3364
+ f"and {template_count} templates",
3365
+ )
3366
+
3367
+
3368
+ def _display_playbook_previews(playbooks: list) -> None:
3369
+ """Display preview of generated playbooks."""
3370
+ with st.expander("Preview Generated Playbooks", expanded=True):
3371
+ for playbook in playbooks:
3372
+ conversion_badge = (
3373
+ "AI-Enhanced"
3374
+ if playbook.get("conversion_method") == "AI-enhanced"
3375
+ else "Deterministic"
3376
+ )
3377
+ st.subheader(
3378
+ f"{playbook['cookbook_name']} ({conversion_badge}) - "
3379
+ f"from {playbook['recipe_file']}"
3380
+ )
3381
+ content = playbook["playbook_content"]
3382
+ preview = content[:1000] + "..." if len(content) > 1000 else content
3383
+ st.code(preview, language="yaml")
3384
+ st.divider()
3385
+
3386
+
3387
+ def _display_template_previews(templates: list) -> None:
3388
+ """Display preview of converted templates."""
3389
+ if not templates:
3390
+ return
3391
+
3392
+ with st.expander(
3393
+ f"Preview Converted Templates ({len(templates)} templates)", expanded=True
3394
+ ):
3395
+ for template in templates:
3396
+ st.subheader(
3397
+ f"{template['cookbook_name']}/templates/{template['template_file']}"
3398
+ )
3399
+ st.caption(f"Converted from: {template['original_file']}")
3400
+
3401
+ # Show extracted variables
3402
+ if template.get("variables"):
3403
+ with st.container():
3404
+ st.write("**Variables used in template:**")
3405
+ st.code(", ".join(template["variables"]), language="text")
3406
+
3407
+ # Show template content preview
3408
+ content = template["template_content"]
3409
+ preview = content[:500] + "..." if len(content) > 500 else content
3410
+ st.code(preview, language="jinja2")
3411
+ st.divider()
3412
+
3413
+
3414
+ def _create_playbook_archive(playbooks, templates=None):
3415
+ """Create a ZIP archive containing all generated Ansible playbooks and templates."""
3416
+ zip_buffer = io.BytesIO()
3417
+ templates = templates or []
3418
+
3419
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
3420
+ # Organize playbooks by cookbook in subdirectories
3421
+ for playbook in playbooks:
3422
+ # Create cookbook directory structure with sanitised names
3423
+ cookbook_name = _sanitize_filename(playbook["cookbook_name"])
3424
+ recipe_name = _sanitize_filename(playbook["recipe_file"].replace(".rb", ""))
3425
+ playbook_filename = f"{cookbook_name}/{recipe_name}.yml"
3426
+ zip_file.writestr(playbook_filename, playbook["playbook_content"])
3427
+
3428
+ # Add converted templates
3429
+ for template in templates:
3430
+ cookbook_name = _sanitize_filename(template["cookbook_name"])
3431
+ template_filename = _sanitize_filename(template["template_file"])
3432
+ archive_path = f"{cookbook_name}/templates/{template_filename}"
3433
+ zip_file.writestr(archive_path, template["template_content"])
3434
+
3435
+ # Count unique cookbooks
3436
+ unique_cookbooks = len({p["cookbook_name"] for p in playbooks})
3437
+ template_count = len(templates)
3438
+
3439
+ # Add a summary README
3440
+ readme_content = f"""# Ansible Playbooks Generated by SousChef
3441
+
3442
+ This archive contains {len(playbooks)} Ansible playbooks and {template_count} """
3443
+ readme_content += f"templates from {unique_cookbooks} cookbook(s) "
3444
+ readme_content += "converted from Chef."
3445
+
3446
+ readme_content += """
3447
+
3448
+ ## Contents:
3449
+ """
3450
+
3451
+ # Group by cookbook for README
3452
+ from collections import defaultdict
3453
+
3454
+ by_cookbook = defaultdict(list)
3455
+ for playbook in playbooks:
3456
+ by_cookbook[playbook["cookbook_name"]].append(playbook)
3457
+
3458
+ # Group templates by cookbook
3459
+ by_cookbook_templates = defaultdict(list)
3460
+ for template in templates:
3461
+ by_cookbook_templates[template["cookbook_name"]].append(template)
3462
+
3463
+ for cookbook_name, cookbook_playbooks in sorted(by_cookbook.items()):
3464
+ cookbook_templates = by_cookbook_templates.get(cookbook_name, [])
3465
+ # Sanitise cookbook name for display in README
3466
+ safe_cookbook_name = _sanitize_filename(cookbook_name)
3467
+ readme_content += (
3468
+ f"\n### {safe_cookbook_name}/ "
3469
+ f"({len(cookbook_playbooks)} recipes, "
3470
+ f"{len(cookbook_templates)} templates)\n"
3471
+ )
3472
+ for playbook in cookbook_playbooks:
3473
+ conversion_method = playbook.get("conversion_method", "Deterministic")
3474
+ recipe_name = playbook["recipe_file"].replace(".rb", "")
3475
+ safe_recipe_name = _sanitize_filename(recipe_name)
3476
+ readme_content += (
3477
+ f" - {safe_recipe_name}.yml "
3478
+ f"(from {playbook['recipe_file']}, "
3479
+ f"{conversion_method})\n"
3480
+ )
3481
+ if cookbook_templates:
3482
+ readme_content += " - templates/\n"
3483
+ for template in cookbook_templates:
3484
+ safe_template_name = _sanitize_filename(template["template_file"])
3485
+ readme_content += (
3486
+ f" - {safe_template_name} "
3487
+ f"(from {template['original_file']})\n"
3488
+ )
3489
+
3490
+ readme_content += """
3491
+
3492
+ ## Usage:
3493
+ Run these playbooks with Ansible:
3494
+ ansible-playbook <cookbook_name>/<recipe_name>.yml
3495
+
3496
+ ## Notes:
3497
+ - These playbooks were automatically generated from Chef recipes
3498
+ - Templates have been converted from ERB to Jinja2 format
3499
+ - Each cookbook's recipes and templates are organized in separate directories
3500
+ - Review and test before deploying to production
3501
+ - Review and test the playbooks before using in production
3502
+ - Some manual adjustments may be required for complex recipes or templates
3503
+ - Verify that template variables are correctly mapped from Chef to Ansible
3504
+ """
3505
+
3506
+ zip_file.writestr("README.md", readme_content)
3507
+
3508
+ zip_buffer.seek(0)
3509
+ return zip_buffer.getvalue()
3510
+
3511
+
3512
+ def _create_analysis_report(results):
3513
+ """Create a JSON report of the analysis results."""
3514
+ report = {
3515
+ "analysis_summary": {
3516
+ "total_cookbooks": len(results),
3517
+ "successful_analyses": len(
3518
+ [r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
3519
+ ),
3520
+ "total_estimated_hours": sum(r.get("estimated_hours", 0) for r in results),
3521
+ "high_complexity_count": len(
3522
+ [r for r in results if r.get("complexity") == "High"]
3523
+ ),
3524
+ },
3525
+ "cookbook_details": results,
3526
+ "generated_at": str(pd.Timestamp.now()),
3527
+ }
3528
+
3529
+ return json.dumps(report, indent=2)