mcp-souschef 2.1.2__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.
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/METADATA +200 -19
- mcp_souschef-2.5.3.dist-info/RECORD +38 -0
- mcp_souschef-2.5.3.dist-info/entry_points.txt +4 -0
- souschef/assessment.py +531 -180
- souschef/ci/__init__.py +11 -0
- souschef/ci/github_actions.py +379 -0
- souschef/ci/gitlab_ci.py +299 -0
- souschef/ci/jenkins_pipeline.py +343 -0
- souschef/cli.py +691 -1
- souschef/converters/playbook.py +43 -5
- souschef/converters/resource.py +146 -49
- souschef/core/__init__.py +22 -0
- souschef/core/errors.py +275 -0
- souschef/core/validation.py +35 -2
- souschef/deployment.py +414 -100
- souschef/filesystem/operations.py +0 -7
- souschef/parsers/__init__.py +6 -1
- souschef/parsers/habitat.py +35 -6
- souschef/parsers/inspec.py +415 -52
- souschef/parsers/metadata.py +89 -23
- souschef/profiling.py +568 -0
- souschef/server.py +948 -255
- souschef/ui/__init__.py +8 -0
- souschef/ui/app.py +1837 -0
- souschef/ui/pages/cookbook_analysis.py +425 -0
- mcp_souschef-2.1.2.dist-info/RECORD +0 -29
- mcp_souschef-2.1.2.dist-info/entry_points.txt +0 -4
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/licenses/LICENSE +0 -0
souschef/cli.py
CHANGED
|
@@ -11,10 +11,18 @@ from typing import NoReturn
|
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
13
|
|
|
14
|
+
from souschef.converters.playbook import generate_playbook_from_recipe
|
|
15
|
+
from souschef.profiling import (
|
|
16
|
+
generate_cookbook_performance_report,
|
|
17
|
+
profile_function,
|
|
18
|
+
)
|
|
14
19
|
from souschef.server import (
|
|
15
20
|
convert_inspec_to_test,
|
|
16
21
|
convert_resource_to_task,
|
|
22
|
+
generate_github_workflow_from_chef,
|
|
23
|
+
generate_gitlab_ci_from_chef,
|
|
17
24
|
generate_inspec_from_recipe,
|
|
25
|
+
generate_jenkinsfile_from_chef,
|
|
18
26
|
list_cookbook_structure,
|
|
19
27
|
list_directory,
|
|
20
28
|
parse_attributes,
|
|
@@ -26,6 +34,11 @@ from souschef.server import (
|
|
|
26
34
|
read_file,
|
|
27
35
|
)
|
|
28
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
|
+
|
|
29
42
|
|
|
30
43
|
@click.group()
|
|
31
44
|
@click.version_option(version="0.1.0", prog_name="souschef")
|
|
@@ -346,7 +359,7 @@ def inspec_parse(path: str, output_format: str) -> None:
|
|
|
346
359
|
@click.option(
|
|
347
360
|
"--format",
|
|
348
361
|
"output_format",
|
|
349
|
-
type=click.Choice(["testinfra", "ansible_assert"]),
|
|
362
|
+
type=click.Choice(["testinfra", "ansible_assert", "serverspec", "goss"]),
|
|
350
363
|
default="testinfra",
|
|
351
364
|
help="Output format for converted tests",
|
|
352
365
|
)
|
|
@@ -378,6 +391,241 @@ def inspec_generate(path: str, output_format: str) -> None:
|
|
|
378
391
|
_output_result(result, output_format)
|
|
379
392
|
|
|
380
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
|
+
|
|
381
629
|
def _output_json_format(result: str) -> None:
|
|
382
630
|
"""Output result as JSON format."""
|
|
383
631
|
try:
|
|
@@ -425,6 +673,448 @@ def _output_result(result: str, output_format: str) -> None:
|
|
|
425
673
|
_output_text_format(result)
|
|
426
674
|
|
|
427
675
|
|
|
676
|
+
@cli.command()
|
|
677
|
+
@click.argument("cookbook_path", type=click.Path(exists=True))
|
|
678
|
+
@click.option(
|
|
679
|
+
"--output",
|
|
680
|
+
"-o",
|
|
681
|
+
type=click.Path(),
|
|
682
|
+
help="Save report to file instead of printing to stdout",
|
|
683
|
+
)
|
|
684
|
+
def profile(cookbook_path: str, output: str | None) -> None:
|
|
685
|
+
"""
|
|
686
|
+
Profile cookbook parsing performance and generate optimization report.
|
|
687
|
+
|
|
688
|
+
COOKBOOK_PATH: Path to the Chef cookbook to profile
|
|
689
|
+
|
|
690
|
+
This command analyzes the performance of parsing all cookbook components
|
|
691
|
+
(recipes, attributes, resources, templates) and provides recommendations
|
|
692
|
+
for optimization.
|
|
693
|
+
"""
|
|
694
|
+
try:
|
|
695
|
+
click.echo(f"Profiling cookbook: {cookbook_path}")
|
|
696
|
+
click.echo("This may take a moment for large cookbooks...")
|
|
697
|
+
|
|
698
|
+
report = generate_cookbook_performance_report(cookbook_path)
|
|
699
|
+
report_text = str(report)
|
|
700
|
+
|
|
701
|
+
if output:
|
|
702
|
+
Path(output).write_text(report_text)
|
|
703
|
+
click.echo(f"✓ Performance report saved to: {output}")
|
|
704
|
+
else:
|
|
705
|
+
click.echo(report_text)
|
|
706
|
+
|
|
707
|
+
except Exception as e:
|
|
708
|
+
click.echo(f"Error profiling cookbook: {e}", err=True)
|
|
709
|
+
sys.exit(1)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@cli.command()
|
|
713
|
+
@click.argument(
|
|
714
|
+
"operation",
|
|
715
|
+
type=click.Choice(["recipe", "attributes", "resource", "template"]),
|
|
716
|
+
)
|
|
717
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
718
|
+
@click.option(
|
|
719
|
+
"--detailed",
|
|
720
|
+
is_flag=True,
|
|
721
|
+
help="Show detailed function call statistics",
|
|
722
|
+
)
|
|
723
|
+
def profile_operation(operation: str, path: str, detailed: bool) -> None:
|
|
724
|
+
"""
|
|
725
|
+
Profile a single parsing operation in detail.
|
|
726
|
+
|
|
727
|
+
OPERATION: Type of operation to profile
|
|
728
|
+
PATH: Path to the file to parse
|
|
729
|
+
|
|
730
|
+
This command profiles a single parsing operation and shows
|
|
731
|
+
execution time, memory usage, and optionally detailed function statistics.
|
|
732
|
+
"""
|
|
733
|
+
operation_map = {
|
|
734
|
+
"recipe": parse_recipe,
|
|
735
|
+
"attributes": parse_attributes,
|
|
736
|
+
"resource": parse_custom_resource,
|
|
737
|
+
"template": parse_template,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
func = operation_map[operation]
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
click.echo(f"Profiling {operation} parsing: {path}")
|
|
744
|
+
|
|
745
|
+
if detailed:
|
|
746
|
+
from souschef.profiling import detailed_profile_function
|
|
747
|
+
|
|
748
|
+
_, profile_result = detailed_profile_function(func, path)
|
|
749
|
+
click.echo(str(profile_result))
|
|
750
|
+
if profile_result.function_stats.get("top_functions"):
|
|
751
|
+
click.echo("\nDetailed Function Statistics:")
|
|
752
|
+
click.echo(profile_result.function_stats["top_functions"])
|
|
753
|
+
else:
|
|
754
|
+
_, profile_result = profile_function(func, path)
|
|
755
|
+
click.echo(str(profile_result))
|
|
756
|
+
|
|
757
|
+
except Exception as e:
|
|
758
|
+
click.echo(f"Error profiling operation: {e}", err=True)
|
|
759
|
+
sys.exit(1)
|
|
760
|
+
|
|
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
|
+
|
|
428
1118
|
def main() -> NoReturn:
|
|
429
1119
|
"""Run the CLI."""
|
|
430
1120
|
cli()
|