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.
- {mcp_souschef-2.2.0.dist-info → mcp_souschef-2.5.3.dist-info}/METADATA +174 -21
- 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 +100 -17
- 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 +601 -1
- souschef/core/validation.py +35 -2
- souschef/deployment.py +5 -3
- souschef/filesystem/operations.py +0 -7
- souschef/parsers/__init__.py +6 -1
- souschef/parsers/inspec.py +343 -18
- souschef/parsers/metadata.py +30 -0
- souschef/server.py +394 -141
- souschef/ui/__init__.py +8 -0
- souschef/ui/app.py +1837 -0
- souschef/ui/pages/cookbook_analysis.py +425 -0
- mcp_souschef-2.2.0.dist-info/RECORD +0 -31
- mcp_souschef-2.2.0.dist-info/entry_points.txt +0 -4
- {mcp_souschef-2.2.0.dist-info → mcp_souschef-2.5.3.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.2.0.dist-info → mcp_souschef-2.5.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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.
|
souschef/parsers/__init__.py
CHANGED
|
@@ -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
|
|
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",
|
souschef/parsers/inspec.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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'
|
|
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 "
|
|
100
|
+
return "Error: No controls found in InSpec profile"
|
|
77
101
|
|
|
78
102
|
# Convert each control
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
894
|
+
f"- name: Verify {control.get('title') or control['id']}",
|
|
570
895
|
" ansible.builtin.assert:",
|
|
571
896
|
" that:",
|
|
572
897
|
]
|
souschef/parsers/metadata.py
CHANGED
|
@@ -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:
|