mcp-souschef 2.2.0__py3-none-any.whl → 2.5.3__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.
souschef/cli.py CHANGED
@@ -11,6 +11,7 @@ from typing import NoReturn
11
11
 
12
12
  import click
13
13
 
14
+ from souschef.converters.playbook import generate_playbook_from_recipe
14
15
  from souschef.profiling import (
15
16
  generate_cookbook_performance_report,
16
17
  profile_function,
@@ -18,7 +19,10 @@ from souschef.profiling import (
18
19
  from souschef.server import (
19
20
  convert_inspec_to_test,
20
21
  convert_resource_to_task,
22
+ generate_github_workflow_from_chef,
23
+ generate_gitlab_ci_from_chef,
21
24
  generate_inspec_from_recipe,
25
+ generate_jenkinsfile_from_chef,
22
26
  list_cookbook_structure,
23
27
  list_directory,
24
28
  parse_attributes,
@@ -30,6 +34,11 @@ from souschef.server import (
30
34
  read_file,
31
35
  )
32
36
 
37
+ # CI/CD job description constants
38
+ CI_JOB_LINT = " • Lint (cookstyle/foodcritic)"
39
+ CI_JOB_UNIT_TESTS = " • Unit Tests (ChefSpec)"
40
+ CI_JOB_INTEGRATION_TESTS = " • Integration Tests (Test Kitchen)"
41
+
33
42
 
34
43
  @click.group()
35
44
  @click.version_option(version="0.1.0", prog_name="souschef")
@@ -350,7 +359,7 @@ def inspec_parse(path: str, output_format: str) -> None:
350
359
  @click.option(
351
360
  "--format",
352
361
  "output_format",
353
- type=click.Choice(["testinfra", "ansible_assert"]),
362
+ type=click.Choice(["testinfra", "ansible_assert", "serverspec", "goss"]),
354
363
  default="testinfra",
355
364
  help="Output format for converted tests",
356
365
  )
@@ -382,6 +391,241 @@ def inspec_generate(path: str, output_format: str) -> None:
382
391
  _output_result(result, output_format)
383
392
 
384
393
 
394
+ @cli.command()
395
+ @click.argument("cookbook_path", type=click.Path(exists=True))
396
+ @click.option(
397
+ "--output",
398
+ "-o",
399
+ type=click.Path(),
400
+ help="Output file path for Jenkinsfile (default: ./Jenkinsfile)",
401
+ )
402
+ @click.option(
403
+ "--pipeline-type",
404
+ type=click.Choice(["declarative", "scripted"]),
405
+ default="declarative",
406
+ help="Jenkins pipeline type (default: declarative)",
407
+ )
408
+ @click.option(
409
+ "--parallel/--no-parallel",
410
+ default=True,
411
+ help="Enable parallel test execution (default: enabled)",
412
+ )
413
+ def generate_jenkinsfile(
414
+ cookbook_path: str, output: str | None, pipeline_type: str, parallel: bool
415
+ ) -> None:
416
+ """
417
+ Generate Jenkinsfile for Chef cookbook CI/CD.
418
+
419
+ COOKBOOK_PATH: Path to the Chef cookbook root directory
420
+
421
+ This command analyzes the cookbook for CI patterns (Test Kitchen,
422
+ lint tools, test suites) and generates an appropriate Jenkinsfile
423
+ with stages for linting, testing, and convergence.
424
+
425
+ Examples:
426
+ souschef generate-jenkinsfile ./mycookbook
427
+
428
+ souschef generate-jenkinsfile ./mycookbook -o Jenkinsfile.new
429
+
430
+ souschef generate-jenkinsfile ./mycookbook --pipeline-type scripted
431
+
432
+ souschef generate-jenkinsfile ./mycookbook --no-parallel
433
+
434
+ """
435
+ try:
436
+ result = generate_jenkinsfile_from_chef(
437
+ cookbook_path=cookbook_path,
438
+ pipeline_type=pipeline_type,
439
+ enable_parallel="yes" if parallel else "no",
440
+ )
441
+
442
+ # Determine output path
443
+ output_path = Path(output) if output else Path.cwd() / "Jenkinsfile"
444
+
445
+ # Write Jenkinsfile
446
+ output_path.write_text(result)
447
+ click.echo(f"✓ Generated {pipeline_type} Jenkinsfile: {output_path}")
448
+
449
+ # Show summary
450
+ click.echo("\nGenerated Pipeline Stages:")
451
+ if "stage('Lint')" in result or "stage 'Lint'" in result:
452
+ click.echo(CI_JOB_LINT)
453
+ if "stage('Unit Tests')" in result or "stage 'Unit Tests'" in result:
454
+ click.echo(CI_JOB_UNIT_TESTS)
455
+ integration_stage = (
456
+ "stage('Integration Tests')" in result
457
+ or "stage 'Integration Tests'" in result
458
+ )
459
+ if integration_stage:
460
+ click.echo(CI_JOB_INTEGRATION_TESTS)
461
+
462
+ if parallel:
463
+ click.echo("\nParallel execution: Enabled")
464
+ else:
465
+ click.echo("\nParallel execution: Disabled")
466
+
467
+ except Exception as e:
468
+ click.echo(f"Error generating Jenkinsfile: {e}", err=True)
469
+ sys.exit(1)
470
+
471
+
472
+ @cli.command()
473
+ @click.argument("cookbook_path", type=click.Path(exists=True))
474
+ @click.option(
475
+ "--output",
476
+ "-o",
477
+ type=click.Path(),
478
+ help="Output file path for .gitlab-ci.yml (default: ./.gitlab-ci.yml)",
479
+ )
480
+ @click.option(
481
+ "--cache/--no-cache",
482
+ default=True,
483
+ help="Enable dependency caching (default: enabled)",
484
+ )
485
+ @click.option(
486
+ "--artifacts/--no-artifacts",
487
+ default=True,
488
+ help="Enable test report artifacts (default: enabled)",
489
+ )
490
+ def generate_gitlab_ci(
491
+ cookbook_path: str, output: str | None, cache: bool, artifacts: bool
492
+ ) -> None:
493
+ """
494
+ Generate .gitlab-ci.yml for Chef cookbook CI/CD.
495
+
496
+ COOKBOOK_PATH: Path to the Chef cookbook root directory
497
+
498
+ This command analyzes the cookbook for CI patterns (Test Kitchen,
499
+ lint tools, test suites) and generates an appropriate GitLab CI
500
+ configuration with jobs for linting, testing, and convergence.
501
+
502
+ Examples:
503
+ souschef generate-gitlab-ci ./mycookbook
504
+
505
+ souschef generate-gitlab-ci ./mycookbook -o .gitlab-ci.test.yml
506
+
507
+ souschef generate-gitlab-ci ./mycookbook --no-cache
508
+
509
+ souschef generate-gitlab-ci ./mycookbook --no-artifacts
510
+
511
+ """
512
+ try:
513
+ result = generate_gitlab_ci_from_chef(
514
+ cookbook_path=cookbook_path,
515
+ enable_cache="yes" if cache else "no",
516
+ enable_artifacts="yes" if artifacts else "no",
517
+ )
518
+
519
+ # Determine output path
520
+ output_path = Path(output) if output else Path.cwd() / ".gitlab-ci.yml"
521
+
522
+ # Write GitLab CI config
523
+ output_path.write_text(result)
524
+ click.echo(f"✓ Generated GitLab CI configuration: {output_path}")
525
+
526
+ # Show summary
527
+ click.echo("\nGenerated CI Jobs:")
528
+ if "cookstyle:" in result or "foodcritic:" in result:
529
+ click.echo(CI_JOB_LINT)
530
+ if "unit-test:" in result or "chefspec:" in result:
531
+ click.echo(CI_JOB_UNIT_TESTS)
532
+ if "integration-test:" in result or "kitchen-" in result:
533
+ click.echo(CI_JOB_INTEGRATION_TESTS)
534
+
535
+ click.echo(f"\nCache: {'Enabled' if cache else 'Disabled'}")
536
+ click.echo(f"Artifacts: {'Enabled' if artifacts else 'Disabled'}")
537
+
538
+ except Exception as e:
539
+ click.echo(f"Error generating GitLab CI configuration: {e}", err=True)
540
+ sys.exit(1)
541
+
542
+
543
+ @cli.command()
544
+ @click.argument("cookbook_path", type=click.Path(exists=True))
545
+ @click.option(
546
+ "--output",
547
+ "-o",
548
+ type=click.Path(),
549
+ help="Output file path for workflow (default: ./.github/workflows/ci.yml)",
550
+ )
551
+ @click.option(
552
+ "--workflow-name",
553
+ default="Chef Cookbook CI",
554
+ help="GitHub Actions workflow name (default: Chef Cookbook CI)",
555
+ )
556
+ @click.option(
557
+ "--cache/--no-cache",
558
+ default=True,
559
+ help="Enable dependency caching (default: enabled)",
560
+ )
561
+ @click.option(
562
+ "--artifacts/--no-artifacts",
563
+ default=True,
564
+ help="Enable test report artifacts (default: enabled)",
565
+ )
566
+ def generate_github_workflow(
567
+ cookbook_path: str,
568
+ output: str | None,
569
+ workflow_name: str,
570
+ cache: bool,
571
+ artifacts: bool,
572
+ ) -> None:
573
+ """
574
+ Generate GitHub Actions workflow for Chef cookbook CI/CD.
575
+
576
+ COOKBOOK_PATH: Path to the Chef cookbook root directory
577
+
578
+ This command analyzes the cookbook for CI patterns (Test Kitchen,
579
+ lint tools, test suites) and generates an appropriate GitHub Actions
580
+ workflow with jobs for linting, testing, and convergence.
581
+
582
+ Examples:
583
+ souschef generate-github-workflow ./mycookbook
584
+
585
+ souschef generate-github-workflow ./mycookbook -o .github/workflows/test.yml
586
+
587
+ souschef generate-github-workflow ./mycookbook --no-cache
588
+
589
+ souschef generate-github-workflow ./mycookbook --workflow-name "CI Pipeline"
590
+
591
+ """
592
+ try:
593
+ result = generate_github_workflow_from_chef(
594
+ cookbook_path=cookbook_path,
595
+ workflow_name=workflow_name,
596
+ enable_cache="yes" if cache else "no",
597
+ enable_artifacts="yes" if artifacts else "no",
598
+ )
599
+
600
+ # Determine output path
601
+ if output:
602
+ output_path = Path(output)
603
+ else:
604
+ workflows_dir = Path.cwd() / ".github" / "workflows"
605
+ workflows_dir.mkdir(parents=True, exist_ok=True)
606
+ output_path = workflows_dir / "ci.yml"
607
+
608
+ # Write workflow file
609
+ output_path.write_text(result)
610
+ click.echo(f"✓ Generated GitHub Actions workflow: {output_path}")
611
+
612
+ # Show summary
613
+ click.echo("\nGenerated Workflow Jobs:")
614
+ if "lint:" in result:
615
+ click.echo(CI_JOB_LINT)
616
+ if "unit-test:" in result:
617
+ click.echo(CI_JOB_UNIT_TESTS)
618
+ if "integration-test:" in result:
619
+ click.echo(CI_JOB_INTEGRATION_TESTS)
620
+
621
+ click.echo(f"\nCache: {'Enabled' if cache else 'Disabled'}")
622
+ click.echo(f"Artifacts: {'Enabled' if artifacts else 'Disabled'}")
623
+
624
+ except Exception as e:
625
+ click.echo(f"Error generating GitHub Actions workflow: {e}", err=True)
626
+ sys.exit(1)
627
+
628
+
385
629
  def _output_json_format(result: str) -> None:
386
630
  """Output result as JSON format."""
387
631
  try:
@@ -515,6 +759,362 @@ def profile_operation(operation: str, path: str, detailed: bool) -> None:
515
759
  sys.exit(1)
516
760
 
517
761
 
762
+ @cli.command("convert-recipe")
763
+ @click.option(
764
+ "--cookbook-path",
765
+ required=True,
766
+ type=click.Path(exists=True),
767
+ help="Path to the Chef cookbook directory",
768
+ )
769
+ @click.option(
770
+ "--recipe-name",
771
+ default="default",
772
+ help="Name of the recipe to convert (default: default)",
773
+ )
774
+ @click.option(
775
+ "--output-path",
776
+ required=True,
777
+ type=click.Path(),
778
+ help="Directory where Ansible playbook will be written",
779
+ )
780
+ def convert_recipe(cookbook_path: str, recipe_name: str, output_path: str) -> None:
781
+ r"""
782
+ Convert a Chef recipe to an Ansible playbook.
783
+
784
+ This command converts a Chef recipe to an Ansible playbook and writes
785
+ it to the specified output path. Used by the Terraform provider.
786
+
787
+ Example:
788
+ souschef convert-recipe --cookbook-path /chef/cookbooks/nginx \\
789
+ --recipe-name default \\
790
+ --output-path /ansible/playbooks
791
+
792
+ """
793
+ try:
794
+ cookbook_dir = Path(cookbook_path)
795
+ output_dir = Path(output_path)
796
+ output_dir.mkdir(parents=True, exist_ok=True)
797
+
798
+ # Check recipe exists
799
+ recipe_file = cookbook_dir / "recipes" / f"{recipe_name}.rb"
800
+ if not recipe_file.exists():
801
+ click.echo(
802
+ f"Error: Recipe {recipe_name}.rb not found in {cookbook_path}/recipes",
803
+ err=True,
804
+ )
805
+ sys.exit(1)
806
+
807
+ # Get cookbook name
808
+ metadata_file = cookbook_dir / "metadata.rb"
809
+ cookbook_name = cookbook_dir.name # Default to directory name
810
+
811
+ if metadata_file.exists():
812
+ metadata_result = read_cookbook_metadata(str(metadata_file))
813
+ # Try to parse cookbook name from metadata
814
+ for line in metadata_result.split("\n"):
815
+ if line.startswith("name"):
816
+ cookbook_name = line.split(":", 1)[1].strip()
817
+ break
818
+
819
+ # Generate playbook
820
+ click.echo(f"Converting {cookbook_name}::{recipe_name} to Ansible...")
821
+ playbook_yaml = generate_playbook_from_recipe(str(recipe_file))
822
+
823
+ # Write output
824
+ output_file = output_dir / f"{recipe_name}.yml"
825
+ output_file.write_text(playbook_yaml)
826
+
827
+ click.echo(f"✓ Playbook written to: {output_file}")
828
+ click.echo(f" Size: {len(playbook_yaml)} bytes")
829
+
830
+ except Exception as e:
831
+ click.echo(f"Error converting recipe: {e}", err=True)
832
+ sys.exit(1)
833
+
834
+
835
+ @cli.command("assess-cookbook")
836
+ @click.option(
837
+ "--cookbook-path",
838
+ required=True,
839
+ type=click.Path(exists=True),
840
+ help="Path to the Chef cookbook directory",
841
+ )
842
+ @click.option(
843
+ "--format",
844
+ "output_format",
845
+ type=click.Choice(["text", "json"]),
846
+ default="json",
847
+ help="Output format (default: json)",
848
+ )
849
+ def assess_cookbook(cookbook_path: str, output_format: str) -> None:
850
+ """
851
+ Assess a Chef cookbook for migration complexity.
852
+
853
+ Analyzes the cookbook and provides complexity level, recipe/resource counts,
854
+ estimated migration effort, and recommendations. Used by Terraform provider.
855
+
856
+ Example:
857
+ souschef assess-cookbook --cookbook-path /chef/cookbooks/nginx --format json
858
+
859
+ """
860
+ try:
861
+ cookbook_dir = Path(cookbook_path)
862
+ if not cookbook_dir.exists():
863
+ click.echo(
864
+ f"Error: Cookbook path does not exist: {cookbook_path}",
865
+ err=True,
866
+ )
867
+ sys.exit(1)
868
+
869
+ if not cookbook_dir.is_dir():
870
+ click.echo(f"Error: {cookbook_path} is not a directory", err=True)
871
+ sys.exit(1)
872
+
873
+ # Analyze cookbook
874
+ analysis = _analyze_cookbook_for_assessment(cookbook_dir)
875
+
876
+ if output_format == "json":
877
+ click.echo(json.dumps(analysis))
878
+ else:
879
+ _display_assessment_text(cookbook_dir.name, analysis)
880
+
881
+ except Exception as e:
882
+ click.echo(f"Error assessing cookbook: {e}", err=True)
883
+ sys.exit(1)
884
+
885
+
886
+ def _analyze_cookbook_for_assessment(cookbook_dir: Path) -> dict:
887
+ """Analyze cookbook and return assessment data."""
888
+ recipe_count = 0
889
+ resource_count = 0
890
+ recipes_dir = cookbook_dir / "recipes"
891
+
892
+ if recipes_dir.exists():
893
+ recipe_files = list(recipes_dir.glob("*.rb"))
894
+ recipe_count = len(recipe_files)
895
+ for recipe_file in recipe_files:
896
+ content = recipe_file.read_text()
897
+ resource_count += content.count(" do\n") + content.count(" do\r\n")
898
+
899
+ # Determine complexity
900
+ if recipe_count == 0:
901
+ complexity = "Low"
902
+ estimated_hours = 0.5
903
+ elif recipe_count <= 3 and resource_count <= 10:
904
+ complexity = "Low"
905
+ estimated_hours = resource_count * 0.5
906
+ elif recipe_count <= 10 and resource_count <= 50:
907
+ complexity = "Medium"
908
+ estimated_hours = resource_count * 1.0
909
+ else:
910
+ complexity = "High"
911
+ estimated_hours = resource_count * 1.5
912
+
913
+ recommendations = (
914
+ f"Cookbook has {recipe_count} recipes with {resource_count} resources. "
915
+ )
916
+ if complexity == "Low":
917
+ recommendations += "Straightforward migration recommended."
918
+ elif complexity == "Medium":
919
+ recommendations += "Moderate effort required. Consider phased approach."
920
+ else:
921
+ recommendations += (
922
+ "Complex migration. Recommend incremental migration strategy."
923
+ )
924
+
925
+ return {
926
+ "complexity": complexity,
927
+ "recipe_count": recipe_count,
928
+ "resource_count": resource_count,
929
+ "estimated_hours": estimated_hours,
930
+ "recommendations": recommendations,
931
+ }
932
+
933
+
934
+ def _display_assessment_text(cookbook_name: str, analysis: dict) -> None:
935
+ """Display assessment in human-readable text format."""
936
+ click.echo(f"\nCookbook: {cookbook_name}")
937
+ click.echo("=" * 50)
938
+ click.echo(f"Complexity: {analysis['complexity']}")
939
+ click.echo(f"Recipe Count: {analysis['recipe_count']}")
940
+ click.echo(f"Resource Count: {analysis['resource_count']}")
941
+ click.echo(f"Estimated Hours: {analysis['estimated_hours']}")
942
+ click.echo(f"\nRecommendations:\n{analysis['recommendations']}")
943
+
944
+
945
+ @cli.command("convert-habitat")
946
+ @click.option(
947
+ "--plan-path",
948
+ required=True,
949
+ type=click.Path(exists=True),
950
+ help="Path to the Habitat plan.sh file",
951
+ )
952
+ @click.option(
953
+ "--output-path",
954
+ required=True,
955
+ type=click.Path(),
956
+ help="Directory where Dockerfile will be written",
957
+ )
958
+ @click.option(
959
+ "--base-image",
960
+ default="ubuntu:latest",
961
+ help="Base Docker image to use (default: ubuntu:latest)",
962
+ )
963
+ def convert_habitat(plan_path: str, output_path: str, base_image: str) -> None:
964
+ r"""
965
+ Convert a Chef Habitat plan to a Dockerfile.
966
+
967
+ Analyses the Habitat plan.sh file and generates an equivalent Dockerfile
968
+ for containerised deployment. Used by Terraform provider.
969
+
970
+ Example:
971
+ souschef convert-habitat --plan-path /hab/plans/nginx/plan.sh \
972
+ --output-path /docker/nginx --base-image ubuntu:22.04
973
+
974
+ """
975
+ try:
976
+ plan_file = Path(plan_path)
977
+ if not plan_file.exists():
978
+ click.echo(f"Error: Plan file does not exist: {plan_path}", err=True)
979
+ sys.exit(1)
980
+
981
+ if not plan_file.is_file():
982
+ click.echo(f"Error: {plan_path} is not a file", err=True)
983
+ sys.exit(1)
984
+
985
+ output_dir = Path(output_path)
986
+ output_dir.mkdir(parents=True, exist_ok=True)
987
+
988
+ # Call server function to convert
989
+ from souschef.server import convert_habitat_to_dockerfile
990
+
991
+ dockerfile_content = convert_habitat_to_dockerfile(str(plan_path), base_image)
992
+
993
+ # Write Dockerfile
994
+ dockerfile_path = output_dir / "Dockerfile"
995
+ dockerfile_path.write_text(dockerfile_content)
996
+
997
+ click.echo(f"Successfully converted Habitat plan to {dockerfile_path}")
998
+ click.echo(f"Dockerfile size: {len(dockerfile_content)} bytes")
999
+
1000
+ except Exception as e:
1001
+ click.echo(f"Error converting Habitat plan: {e}", err=True)
1002
+ sys.exit(1)
1003
+
1004
+
1005
+ @cli.command("convert-inspec")
1006
+ @click.option(
1007
+ "--profile-path",
1008
+ required=True,
1009
+ type=click.Path(exists=True),
1010
+ help="Path to the InSpec profile directory",
1011
+ )
1012
+ @click.option(
1013
+ "--output-path",
1014
+ required=True,
1015
+ type=click.Path(),
1016
+ help="Directory where converted tests will be written",
1017
+ )
1018
+ @click.option(
1019
+ "--format",
1020
+ "output_format",
1021
+ type=click.Choice(["testinfra", "serverspec", "goss", "ansible"]),
1022
+ default="testinfra",
1023
+ help="Output test framework format (default: testinfra)",
1024
+ )
1025
+ def convert_inspec(profile_path: str, output_path: str, output_format: str) -> None:
1026
+ r"""
1027
+ Convert a Chef InSpec profile to various test frameworks.
1028
+
1029
+ Analyses the InSpec profile and generates equivalent tests in the
1030
+ specified framework. Supports TestInfra, Serverspec, Goss, and Ansible.
1031
+
1032
+ Example:
1033
+ souschef convert-inspec --profile-path /inspec/profiles/linux \
1034
+ --output-path /tests/testinfra --format testinfra
1035
+
1036
+ """
1037
+ try:
1038
+ profile_dir = Path(profile_path)
1039
+ if not profile_dir.exists():
1040
+ click.echo(
1041
+ f"Error: Profile path does not exist: {profile_path}",
1042
+ err=True,
1043
+ )
1044
+ sys.exit(1)
1045
+
1046
+ if not profile_dir.is_dir():
1047
+ click.echo(f"Error: {profile_path} is not a directory", err=True)
1048
+ sys.exit(1)
1049
+
1050
+ output_dir = Path(output_path)
1051
+ output_dir.mkdir(parents=True, exist_ok=True)
1052
+
1053
+ # Call server function to convert
1054
+ from souschef.server import convert_inspec_to_test
1055
+
1056
+ test_content = convert_inspec_to_test(str(profile_path), output_format)
1057
+
1058
+ # Determine output filename based on format
1059
+ filename_map = {
1060
+ "testinfra": "test_spec.py",
1061
+ "serverspec": "spec_helper.rb",
1062
+ "goss": "goss.yaml",
1063
+ "ansible": "assert.yml",
1064
+ }
1065
+ output_filename = filename_map.get(output_format, "test.txt")
1066
+
1067
+ # Write test file
1068
+ test_file_path = output_dir / output_filename
1069
+ test_file_path.write_text(test_content)
1070
+
1071
+ click.echo(f"Successfully converted InSpec profile to {output_format} format")
1072
+ click.echo(f"Test file: {test_file_path}")
1073
+ click.echo(f"File size: {len(test_content)} bytes")
1074
+
1075
+ except Exception as e:
1076
+ click.echo(f"Error converting InSpec profile: {e}", err=True)
1077
+ sys.exit(1)
1078
+
1079
+
1080
+ @cli.command()
1081
+ @click.option("--port", default=8501, help="Port to run the Streamlit app on")
1082
+ def ui(port: int) -> None:
1083
+ """
1084
+ Launch the SousChef Visual Migration Planning Interface.
1085
+
1086
+ Opens a web-based interface for interactive Chef to Ansible migration planning,
1087
+ cookbook analysis, and visualization.
1088
+ """
1089
+ try:
1090
+ import subprocess
1091
+ import sys
1092
+
1093
+ # Launch Streamlit app
1094
+ cmd = [
1095
+ sys.executable,
1096
+ "-m",
1097
+ "streamlit",
1098
+ "run",
1099
+ "souschef/ui/app.py",
1100
+ "--server.port",
1101
+ str(port),
1102
+ ]
1103
+ click.echo(f"Starting SousChef UI on http://localhost:{port}")
1104
+ click.echo("Press Ctrl+C to stop the server")
1105
+
1106
+ subprocess.run(cmd, check=True)
1107
+
1108
+ except subprocess.CalledProcessError as e:
1109
+ click.echo(f"Error starting UI: {e}", err=True)
1110
+ sys.exit(1)
1111
+ except ImportError:
1112
+ click.echo(
1113
+ "Streamlit is not installed. Install with: pip install streamlit", err=True
1114
+ )
1115
+ sys.exit(1)
1116
+
1117
+
518
1118
  def main() -> NoReturn:
519
1119
  """Run the CLI."""
520
1120
  cli()
@@ -234,10 +234,43 @@ class ValidationEngine:
234
234
  if "import pytest" in result:
235
235
  # Testinfra format
236
236
  self._validate_python_syntax(result)
237
- elif "---" in result:
238
- # Ansible assert format
237
+ elif "require 'serverspec'" in result:
238
+ # ServerSpec format (Ruby)
239
+ self._validate_ruby_syntax(result)
240
+ elif "---" in result or ("package:" in result and "service:" in result):
241
+ # Ansible assert or Goss YAML format
239
242
  self._validate_yaml_syntax(result)
240
243
 
244
+ def _validate_ruby_syntax(self, ruby_content: str) -> None:
245
+ """
246
+ Validate Ruby syntax.
247
+
248
+ Args:
249
+ ruby_content: Ruby content to validate.
250
+
251
+ """
252
+ # Basic Ruby syntax checks
253
+ if not ruby_content.strip():
254
+ self._add_result(
255
+ ValidationLevel.ERROR,
256
+ ValidationCategory.SYNTAX,
257
+ "Empty Ruby content",
258
+ suggestion="Ensure the conversion produced valid Ruby code",
259
+ )
260
+ return
261
+
262
+ # Check for balanced blocks (describe/do/end)
263
+ do_count = len(re.findall(r"\bdo\b", ruby_content))
264
+ end_count = len(re.findall(r"\bend\b", ruby_content))
265
+
266
+ if do_count != end_count:
267
+ self._add_result(
268
+ ValidationLevel.ERROR,
269
+ ValidationCategory.SYNTAX,
270
+ f"Unbalanced Ruby blocks: {do_count} 'do' but {end_count} 'end'",
271
+ suggestion="Check that all 'do' blocks have matching 'end' keywords",
272
+ )
273
+
241
274
  def _validate_yaml_syntax(self, yaml_content: str) -> None:
242
275
  """
243
276
  Validate YAML syntax.
souschef/deployment.py CHANGED
@@ -474,14 +474,16 @@ def _validate_canary_inputs(
474
474
  raise ValueError("Steps must be between 1 and 100")
475
475
  if steps != sorted(steps):
476
476
  return None, (
477
- f"Error: Rollout steps must be in ascending order: {rollout_steps}\n\n"
477
+ "Error: Rollout steps must be in ascending order: "
478
+ f"{rollout_steps}\n\n"
478
479
  "Suggestion: Use format like '10,25,50,100'"
479
480
  )
480
481
  return steps, None
481
482
  except ValueError as e:
482
- return None, (
483
+ return (
484
+ None,
483
485
  f"Error: Invalid rollout steps '{rollout_steps}': {e}\n\n"
484
- "Suggestion: Use comma-separated percentages like '10,25,50,100'"
486
+ "Suggestion: Use comma-separated percentages like '10,25,50,100'",
485
487
  )
486
488
 
487
489