mcp-souschef 2.8.0__py3-none-any.whl → 3.2.0__py3-none-any.whl

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