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.
@@ -1,7 +1,5 @@
1
1
  """Filesystem operations for Chef cookbook exploration."""
2
2
 
3
- from mcp.server.fastmcp import FastMCP
4
-
5
3
  from souschef.core.constants import (
6
4
  ERROR_FILE_NOT_FOUND,
7
5
  ERROR_IS_DIRECTORY,
@@ -9,11 +7,7 @@ from souschef.core.constants import (
9
7
  )
10
8
  from souschef.core.path_utils import _normalize_path
11
9
 
12
- # Get MCP instance to register tools
13
- mcp = FastMCP("souschef")
14
-
15
10
 
16
- @mcp.tool()
17
11
  def list_directory(path: str) -> list[str] | str:
18
12
  """
19
13
  List the contents of a directory.
@@ -40,7 +34,6 @@ def list_directory(path: str) -> list[str] | str:
40
34
  return f"An error occurred: {e}"
41
35
 
42
36
 
43
- @mcp.tool()
44
37
  def read_file(path: str) -> str:
45
38
  """
46
39
  Read the contents of a file.
@@ -13,7 +13,11 @@ from souschef.parsers.inspec import (
13
13
  generate_inspec_from_chef,
14
14
  parse_inspec_profile,
15
15
  )
16
- from souschef.parsers.metadata import list_cookbook_structure, read_cookbook_metadata
16
+ from souschef.parsers.metadata import (
17
+ list_cookbook_structure,
18
+ parse_cookbook_metadata,
19
+ read_cookbook_metadata,
20
+ )
17
21
  from souschef.parsers.recipe import parse_recipe
18
22
  from souschef.parsers.resource import parse_custom_resource
19
23
  from souschef.parsers.template import parse_template
@@ -24,6 +28,7 @@ __all__ = [
24
28
  "parse_attributes",
25
29
  "parse_custom_resource",
26
30
  "read_cookbook_metadata",
31
+ "parse_cookbook_metadata",
27
32
  "list_cookbook_structure",
28
33
  "parse_inspec_profile",
29
34
  "convert_inspec_to_test",
@@ -8,6 +8,14 @@ from typing import Any
8
8
  from souschef.core.constants import ERROR_PREFIX, INSPEC_END_INDENT, INSPEC_SHOULD_EXIST
9
9
  from souschef.core.path_utils import _normalize_path, _safe_join
10
10
 
11
+ # Regex patterns used across converters
12
+ _VERSION_PATTERN = r"match\s+/([^/]+)/"
13
+ _MODE_PATTERN = r"cmp\s+'([^']+)'"
14
+ _OWNER_PATTERN = r"eq\s+['\"]([^'\"]+)['\"]"
15
+
16
+ # ServerSpec formatting constants
17
+ _SERVERSPEC_END = " end"
18
+
11
19
 
12
20
  def parse_inspec_profile(path: str) -> str:
13
21
  """
@@ -21,17 +29,32 @@ def parse_inspec_profile(path: str) -> str:
21
29
 
22
30
  """
23
31
  try:
32
+ # Validate input
33
+ if not path or not path.strip():
34
+ return (
35
+ "Error: Path cannot be empty\n\n"
36
+ "Suggestion: Provide a path to an InSpec profile directory "
37
+ "or control file"
38
+ )
39
+
24
40
  profile_path = _normalize_path(path)
25
41
 
26
42
  if not profile_path.exists():
27
- return f"Error: Path does not exist: {path}"
43
+ return (
44
+ f"Error: Path does not exist: {path}\n\n"
45
+ "Suggestion: Check that the path is correct and the InSpec "
46
+ "profile exists"
47
+ )
28
48
 
29
49
  if profile_path.is_dir():
30
50
  controls = _parse_controls_from_directory(profile_path)
31
51
  elif profile_path.is_file():
32
52
  controls = _parse_controls_from_file(profile_path)
33
53
  else:
34
- return f"Error: Invalid path type: {path}"
54
+ return (
55
+ f"Error: Invalid path type: {path}\n\n"
56
+ "Suggestion: Provide a directory or file path, not a special file type"
57
+ )
35
58
 
36
59
  return json.dumps(
37
60
  {
@@ -43,18 +66,19 @@ def parse_inspec_profile(path: str) -> str:
43
66
  )
44
67
 
45
68
  except (FileNotFoundError, RuntimeError) as e:
46
- return f"Error: {e}"
69
+ return f"Error: {e}\n\nSuggestion: Verify the path exists and is accessible"
47
70
  except Exception as e:
48
71
  return f"An error occurred while parsing InSpec profile: {e}"
49
72
 
50
73
 
51
- def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -> str:
74
+ def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -> str: # noqa: C901
52
75
  """
53
- Convert InSpec controls to Ansible test format.
76
+ Convert InSpec controls to test framework format.
54
77
 
55
78
  Args:
56
79
  inspec_path: Path to InSpec profile or control file.
57
- output_format: Output format ('testinfra' or 'ansible_assert').
80
+ output_format: Output format ('testinfra', 'ansible_assert',
81
+ 'serverspec', or 'goss').
58
82
 
59
83
  Returns:
60
84
  Converted test code or error message.
@@ -73,19 +97,51 @@ def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -
73
97
  controls = profile_data["controls"]
74
98
 
75
99
  if not controls:
76
- return "Warning: No controls found to convert"
100
+ return "Error: No controls found in InSpec profile"
77
101
 
78
102
  # Convert each control
79
- converted = []
80
- for control in controls:
81
- if output_format == "testinfra":
82
- converted.append(_convert_inspec_to_testinfra(control))
83
- elif output_format == "ansible_assert":
84
- converted.append(_convert_inspec_to_ansible_assert(control))
85
- else:
86
- return f"Error: Unsupported output format: {output_format}"
103
+ converted_tests = []
104
+
105
+ if output_format == "testinfra":
106
+ converted_tests.append("import pytest")
107
+ converted_tests.append("")
108
+ converted_tests.append("")
109
+ for control in controls:
110
+ test_code = _convert_inspec_to_testinfra(control)
111
+ converted_tests.append(test_code)
112
+
113
+ elif output_format == "ansible_assert":
114
+ converted_tests.append("---")
115
+ converted_tests.append("# Validation tasks converted from InSpec")
116
+ converted_tests.append("")
117
+ for control in controls:
118
+ assert_code = _convert_inspec_to_ansible_assert(control)
119
+ converted_tests.append(assert_code)
120
+ converted_tests.append("")
121
+
122
+ elif output_format == "serverspec":
123
+ converted_tests.append("# frozen_string_literal: true")
124
+ converted_tests.append("")
125
+ converted_tests.append("require 'serverspec'")
126
+ converted_tests.append("")
127
+ converted_tests.append("set :backend, :exec")
128
+ converted_tests.append("")
129
+ for control in controls:
130
+ serverspec_code = _convert_inspec_to_serverspec(control)
131
+ converted_tests.append(serverspec_code)
132
+
133
+ elif output_format == "goss":
134
+ # Goss uses a single YAML spec for all tests
135
+ return _convert_inspec_to_goss(controls)
87
136
 
88
- return "\n".join(converted)
137
+ else:
138
+ error_msg = (
139
+ f"Error: Unsupported format '{output_format}'. "
140
+ "Use 'testinfra', 'ansible_assert', 'serverspec', or 'goss'"
141
+ )
142
+ return error_msg
143
+
144
+ return "\n".join(converted_tests)
89
145
 
90
146
  except json.JSONDecodeError as e:
91
147
  return f"Error parsing InSpec result: {e}"
@@ -388,7 +444,7 @@ def _convert_package_to_testinfra(
388
444
  if "be_installed" in exp["matcher"]:
389
445
  lines.append(" assert pkg.is_installed")
390
446
  elif exp["type"] == "its" and exp["property"] == "version":
391
- version_match = re.search(r"match\s+/([^/]+)/", exp["matcher"])
447
+ version_match = re.search(_VERSION_PATTERN, exp["matcher"])
392
448
  if version_match:
393
449
  version = version_match.group(1)
394
450
  lines.append(f' assert pkg.version.startswith("{version}")')
@@ -500,6 +556,275 @@ def _convert_inspec_to_testinfra(control: dict[str, Any]) -> str:
500
556
  return "\n".join(lines)
501
557
 
502
558
 
559
+ def _convert_package_to_serverspec(
560
+ lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
561
+ ) -> None:
562
+ """
563
+ Convert package resource to ServerSpec expectations.
564
+
565
+ Args:
566
+ lines: List to append test lines to.
567
+ resource_name: Name of the package.
568
+ expectations: List of InSpec expectations.
569
+
570
+ """
571
+ lines.append(f" describe package('{resource_name}') do")
572
+ for exp in expectations:
573
+ if "be_installed" in exp["matcher"]:
574
+ lines.append(" it { should be_installed }")
575
+ elif exp["type"] == "its" and exp["property"] == "version":
576
+ version_match = re.search(_VERSION_PATTERN, exp["matcher"])
577
+ if version_match:
578
+ version = version_match.group(1)
579
+ lines.append(f" its('version') {{ should match /{version}/ }}")
580
+ lines.append(_SERVERSPEC_END)
581
+
582
+
583
+ def _convert_service_to_serverspec(
584
+ lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
585
+ ) -> None:
586
+ """
587
+ Convert service resource to ServerSpec expectations.
588
+
589
+ Args:
590
+ lines: List to append test lines to.
591
+ resource_name: Name of the service.
592
+ expectations: List of InSpec expectations.
593
+
594
+ """
595
+ lines.append(f" describe service('{resource_name}') do")
596
+ for exp in expectations:
597
+ if "be_running" in exp["matcher"]:
598
+ lines.append(" it { should be_running }")
599
+ elif "be_enabled" in exp["matcher"]:
600
+ lines.append(" it { should be_enabled }")
601
+ lines.append(_SERVERSPEC_END)
602
+
603
+
604
+ def _convert_file_to_serverspec(
605
+ lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
606
+ ) -> None:
607
+ """
608
+ Convert file resource to ServerSpec expectations.
609
+
610
+ Args:
611
+ lines: List to append test lines to.
612
+ resource_name: Path to the file.
613
+ expectations: List of InSpec expectations.
614
+
615
+ """
616
+ lines.append(f" describe file('{resource_name}') do")
617
+ for exp in expectations:
618
+ if "exist" in exp["matcher"]:
619
+ lines.append(" it { should exist }")
620
+ elif exp["type"] == "its" and exp["property"] == "mode":
621
+ mode_match = re.search(_MODE_PATTERN, exp["matcher"])
622
+ if mode_match:
623
+ mode = mode_match.group(1)
624
+ lines.append(f" its('mode') {{ should cmp '{mode}' }}")
625
+ elif exp["type"] == "its" and exp["property"] == "owner":
626
+ owner_match = re.search(_OWNER_PATTERN, exp["matcher"])
627
+ if owner_match:
628
+ owner = owner_match.group(1)
629
+ lines.append(f" its('owner') {{ should eq '{owner}' }}")
630
+ lines.append(_SERVERSPEC_END)
631
+
632
+
633
+ def _convert_port_to_serverspec(
634
+ lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
635
+ ) -> None:
636
+ """
637
+ Convert port resource to ServerSpec expectations.
638
+
639
+ Args:
640
+ lines: List to append test lines to.
641
+ resource_name: Port number or address.
642
+ expectations: List of InSpec expectations.
643
+
644
+ """
645
+ lines.append(f" describe port({resource_name}) do")
646
+ for exp in expectations:
647
+ if "be_listening" in exp["matcher"]:
648
+ lines.append(" it { should be_listening }")
649
+ lines.append(_SERVERSPEC_END)
650
+
651
+
652
+ def _convert_inspec_to_serverspec(control: dict[str, Any]) -> str:
653
+ """
654
+ Convert InSpec control to ServerSpec test.
655
+
656
+ Args:
657
+ control: Parsed InSpec control dictionary.
658
+
659
+ Returns:
660
+ ServerSpec test code as string.
661
+
662
+ """
663
+ lines = []
664
+
665
+ # Add describe block for the control
666
+ lines.append(f"describe '{control.get('title') or control['id']}' do")
667
+
668
+ # Convert each test within the control
669
+ for test in control["tests"]:
670
+ resource_type = test["resource_type"]
671
+ resource_name = test["resource_name"]
672
+ expectations = test["expectations"]
673
+
674
+ # Map InSpec resources to ServerSpec using dedicated converters
675
+ if resource_type == "package":
676
+ _convert_package_to_serverspec(lines, resource_name, expectations)
677
+ elif resource_type == "service":
678
+ _convert_service_to_serverspec(lines, resource_name, expectations)
679
+ elif resource_type == "file":
680
+ _convert_file_to_serverspec(lines, resource_name, expectations)
681
+ elif resource_type == "port":
682
+ _convert_port_to_serverspec(lines, resource_name, expectations)
683
+
684
+ lines.append("end")
685
+ lines.append("")
686
+ return "\n".join(lines)
687
+
688
+
689
+ def _convert_package_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
690
+ """
691
+ Convert package resource to Goss specification.
692
+
693
+ Args:
694
+ expectations: List of InSpec expectations.
695
+
696
+ Returns:
697
+ Goss package specification dictionary.
698
+
699
+ """
700
+ spec: dict[str, Any] = {}
701
+ for exp in expectations:
702
+ if "be_installed" in exp["matcher"]:
703
+ spec["installed"] = True
704
+ elif exp["type"] == "its" and exp["property"] == "version":
705
+ version_match = re.search(_VERSION_PATTERN, exp["matcher"])
706
+ if version_match:
707
+ spec["versions"] = [version_match.group(1)]
708
+ return spec
709
+
710
+
711
+ def _convert_service_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
712
+ """
713
+ Convert service resource to Goss specification.
714
+
715
+ Args:
716
+ expectations: List of InSpec expectations.
717
+
718
+ Returns:
719
+ Goss service specification dictionary.
720
+
721
+ """
722
+ spec: dict[str, Any] = {}
723
+ for exp in expectations:
724
+ if "be_running" in exp["matcher"]:
725
+ spec["running"] = True
726
+ elif "be_enabled" in exp["matcher"]:
727
+ spec["enabled"] = True
728
+ return spec
729
+
730
+
731
+ def _convert_file_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
732
+ """
733
+ Convert file resource to Goss specification.
734
+
735
+ Args:
736
+ expectations: List of InSpec expectations.
737
+
738
+ Returns:
739
+ Goss file specification dictionary.
740
+
741
+ """
742
+ spec: dict[str, Any] = {}
743
+ for exp in expectations:
744
+ if "exist" in exp["matcher"]:
745
+ spec["exists"] = True
746
+ elif exp["type"] == "its" and exp["property"] == "mode":
747
+ mode_match = re.search(_MODE_PATTERN, exp["matcher"])
748
+ if mode_match:
749
+ spec["mode"] = mode_match.group(1)
750
+ elif exp["type"] == "its" and exp["property"] == "owner":
751
+ owner_match = re.search(_OWNER_PATTERN, exp["matcher"])
752
+ if owner_match:
753
+ spec["owner"] = owner_match.group(1)
754
+ return spec
755
+
756
+
757
+ def _convert_port_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
758
+ """
759
+ Convert port resource to Goss specification.
760
+
761
+ Args:
762
+ expectations: List of InSpec expectations.
763
+
764
+ Returns:
765
+ Goss port specification dictionary.
766
+
767
+ """
768
+ spec: dict[str, Any] = {}
769
+ for exp in expectations:
770
+ if "be_listening" in exp["matcher"]:
771
+ spec["listening"] = True
772
+ return spec
773
+
774
+
775
+ def _convert_inspec_to_goss(controls: list[dict[str, Any]]) -> str:
776
+ """
777
+ Convert InSpec controls to Goss YAML specification.
778
+
779
+ Args:
780
+ controls: List of parsed InSpec control dictionaries.
781
+
782
+ Returns:
783
+ Goss specification as YAML string.
784
+
785
+ """
786
+ goss_spec: dict[str, dict[str, Any]] = {
787
+ "package": {},
788
+ "service": {},
789
+ "file": {},
790
+ "port": {},
791
+ }
792
+
793
+ # Process all controls and group by resource type
794
+ for control in controls:
795
+ for test in control["tests"]:
796
+ resource_type = test["resource_type"]
797
+ resource_name = test["resource_name"]
798
+ expectations = test["expectations"]
799
+
800
+ if resource_type == "package":
801
+ spec = _convert_package_to_goss(expectations)
802
+ goss_spec["package"][resource_name] = spec
803
+ elif resource_type == "service":
804
+ spec = _convert_service_to_goss(expectations)
805
+ goss_spec["service"][resource_name] = spec
806
+ elif resource_type == "file":
807
+ spec = _convert_file_to_goss(expectations)
808
+ goss_spec["file"][resource_name] = spec
809
+ elif resource_type == "port":
810
+ # Goss uses string format for ports
811
+ port_key = f"tcp://{resource_name}"
812
+ spec = _convert_port_to_goss(expectations)
813
+ goss_spec["port"][port_key] = spec
814
+
815
+ # Remove empty sections
816
+ goss_spec = {k: v for k, v in goss_spec.items() if v}
817
+
818
+ # Convert to YAML (using JSON for now, will need PyYAML for proper YAML)
819
+ try:
820
+ import yaml
821
+
822
+ return yaml.dump(goss_spec, default_flow_style=False, sort_keys=False)
823
+ except ImportError:
824
+ # Fallback to JSON if PyYAML not available
825
+ return json.dumps(goss_spec, indent=2)
826
+
827
+
503
828
  def _convert_package_to_ansible_assert(
504
829
  lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
505
830
  ) -> None:
@@ -566,7 +891,7 @@ def _convert_inspec_to_ansible_assert(control: dict[str, Any]) -> str:
566
891
 
567
892
  """
568
893
  lines = [
569
- f"- name: Verify {control['title'] or control['id']}",
894
+ f"- name: Verify {control.get('title') or control['id']}",
570
895
  " ansible.builtin.assert:",
571
896
  " that:",
572
897
  ]
@@ -45,6 +45,36 @@ def read_cookbook_metadata(path: str) -> str:
45
45
  return f"An error occurred: {e}"
46
46
 
47
47
 
48
+ def parse_cookbook_metadata(path: str) -> dict[str, str | list[str]]:
49
+ """
50
+ Parse Chef cookbook metadata.rb file and return as dictionary.
51
+
52
+ Args:
53
+ path: Path to the metadata.rb file.
54
+
55
+ Returns:
56
+ Dictionary containing extracted metadata fields.
57
+
58
+ """
59
+ try:
60
+ file_path = _normalize_path(path)
61
+ content = file_path.read_text(encoding="utf-8")
62
+
63
+ metadata = _extract_metadata(content)
64
+ return metadata
65
+
66
+ except ValueError as e:
67
+ return {"error": str(e)}
68
+ except FileNotFoundError:
69
+ return {"error": ERROR_FILE_NOT_FOUND.format(path=path)}
70
+ except IsADirectoryError:
71
+ return {"error": ERROR_IS_DIRECTORY.format(path=path)}
72
+ except PermissionError:
73
+ return {"error": ERROR_PERMISSION_DENIED.format(path=path)}
74
+ except Exception as e:
75
+ return {"error": f"An error occurred: {e}"}
76
+
77
+
48
78
  def _scan_cookbook_directory(
49
79
  cookbook_path, dir_name: str
50
80
  ) -> tuple[str, list[str]] | None: