Skip to content

Commit 9f1c48e

Browse files
authored
Merge pull request #105 from konflux-ci/ISV-5867
feat(ISV-5867): Update parsing of SBOM inputs
2 parents b1682f9 + ba9c319 commit 9f1c48e

File tree

5 files changed

+108
-12
lines changed

5 files changed

+108
-12
lines changed

docs/sboms/generate_oci_image.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ This utility allows users to generate SBOMs related to Container Images.
44

55
## Architecture
66

7-
The scripts takes at least one SBOM generated by SYFT and at most one SBOM
8-
generated by Hermeto (previously known as Cachi2). It combines these SBOMs
7+
The scripts accepts any number of SBOMs generated by SYFT and at most one SBOM
8+
generated by Hermeto (previously known as Cachi2), with the requirement that
9+
at least one SBOM is provided in total. It combines these SBOMs
910
and takes them as a context of the built image.
1011

1112
The script also parses a JSON-ified Dockerfile of the image, parses its
@@ -35,7 +36,7 @@ mobster --verbose generate oci-image \
3536
```
3637

3738
## List of arguments
38-
- `--from-syft` -- must be present at least once, points to an SBOM file (in a JSON format) created by Syft
39+
- `--from-syft` -- points to an SBOM file (in a JSON format) created by Syft, can be used multiple times
3940
- `--from-hermeto` -- points to an SBOM file (in a JSON format) created by Hermeto
4041
- `--image-pullspec` -- the pullspec of the image processed in the format `<registry>/<repository>:<tag>`
4142
- `--image-digest` -- the digest of the image processed in the format `sha256:<digest value>`

src/mobster/cli.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ def validated_additional_reference(value: str) -> str:
9494
)
9595
oci_image_parser.add_argument(
9696
"--from-syft",
97-
required=True,
9897
type=Path,
9998
help="Path to the SBOM file generated by Syft",
10099
action="append",

src/mobster/cmd/generate/oci_image/__init__.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
from argparse import ArgumentError
78
from pathlib import Path
89
from typing import Any
910

@@ -63,6 +64,39 @@ async def _soft_validate_content(self) -> None:
6364
except CycloneDxException as e:
6465
LOGGER.warning("\n".join(e.args))
6566

67+
def _handle_bom_inputs(
68+
self,
69+
syft_boms: list[Path] | None,
70+
hermeto_bom: Path | None,
71+
) -> dict[str, Any]:
72+
"""
73+
Handles the input SBOM files, merging them if necessary.
74+
Args:
75+
syft_boms (list[Path] | None): List of Syft SBOM file paths.
76+
hermeto_bom (Path | None): Path to a Hermeto SBOM file.
77+
Returns:
78+
dict[str, Any]: Merged/loaded SBOM dictionary.
79+
Raises:
80+
ArgumentError: If neither Syft nor Hermeto SBOMs are provided.
81+
"""
82+
if hermeto_bom is None and syft_boms is None:
83+
raise ArgumentError(
84+
None,
85+
"At least one of --from-syft or --from-hermeto must be provided",
86+
)
87+
88+
def _open_bom(bom: Path) -> dict[str, Any]:
89+
with open(bom, encoding="utf-8") as bom_file:
90+
return json.load(bom_file) # type: ignore[no-any-return]
91+
92+
if syft_boms is not None:
93+
# Merging Syft & Hermeto SBOMs
94+
if len(syft_boms) > 1 or hermeto_bom:
95+
return merge_sboms(syft_boms, hermeto_bom)
96+
return _open_bom(syft_boms[0])
97+
98+
return _open_bom(hermeto_bom) # type: ignore[arg-type]
99+
66100
async def execute(self) -> Any:
67101
"""
68102
Generate an SBOM document for OCI image.
@@ -81,13 +115,7 @@ async def execute(self) -> Any:
81115
# contextualize: bool = self.cli_args.contextualize
82116
# TODO add contextual SBOM utilities # pylint: disable=fixme
83117

84-
# Merging Syft & Hermeto SBOMs
85-
if len(syft_boms) > 1 or hermeto_bom:
86-
merged_sbom_dict = merge_sboms(syft_boms, hermeto_bom)
87-
else:
88-
# Just one image provided, nothing to merge
89-
with open(syft_boms[0], encoding="utf8") as sbom_file:
90-
merged_sbom_dict = json.load(sbom_file)
118+
merged_sbom_dict = self._handle_bom_inputs(syft_boms, hermeto_bom)
91119
sbom: Document | CycloneDX1BomWrapper
92120

93121
# Parsing into objects

tests/cmd/generate/oci_image/test_module_init.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import json
3+
from argparse import ArgumentError
34
from pathlib import Path
45
from typing import Any, Literal
56
from unittest.mock import AsyncMock, MagicMock, patch
@@ -326,3 +327,71 @@ async def test_GenerateOciImageCommand__soft_validate_content_cdx(
326327
command._content = CycloneDX1BomWrapper(sbom=cdx_sbom_object)
327328
await command._soft_validate_content()
328329
mock_logger.warning.assert_called()
330+
331+
332+
@pytest.mark.asyncio
333+
@pytest.mark.parametrize(
334+
["syft_boms", "hermeto_bom", "expected_action"],
335+
[
336+
# no SBOMs
337+
(None, None, "raise_error"),
338+
# single syft
339+
([Path("syft.json")], None, "load_syft"),
340+
# multiple syft
341+
([Path("syft1.json"), Path("syft2.json")], None, "merge"),
342+
# syft + hermeto
343+
([Path("syft.json")], Path("hermeto.json"), "merge"),
344+
# multiple syft + hermeto
345+
([Path("syft1.json"), Path("syft2.json")], Path("hermeto.json"), "merge"),
346+
# only hermeto
347+
(None, Path("hermeto.json"), "load_hermeto"),
348+
],
349+
)
350+
@patch("builtins.open")
351+
@patch("json.load")
352+
@patch("mobster.cmd.generate.oci_image.merge_sboms")
353+
async def test_GenerateOciImageCommand__handle_bom_inputs(
354+
mock_merge: MagicMock,
355+
mock_json_load: MagicMock,
356+
mock_open: MagicMock,
357+
syft_boms: list[Path] | None,
358+
hermeto_bom: Path | None,
359+
expected_action: Literal["raise_error", "load_syft", "load_hermeto", "merge"],
360+
) -> None:
361+
command = GenerateOciImageCommand(MagicMock())
362+
363+
mock_syft_data = {"name": "syft_data"}
364+
mock_hermeto_data = {"name": "hermeto_data"}
365+
mock_merged_data = {"name": "merged_data"}
366+
367+
mock_file = MagicMock()
368+
mock_open.return_value.__enter__.return_value = mock_file
369+
mock_json_load.side_effect = (
370+
lambda _: mock_syft_data if hermeto_bom is None else mock_hermeto_data
371+
)
372+
mock_merge.return_value = mock_merged_data
373+
374+
if expected_action == "raise_error":
375+
with pytest.raises(ArgumentError):
376+
command._handle_bom_inputs(syft_boms, hermeto_bom)
377+
else:
378+
result = command._handle_bom_inputs(syft_boms, hermeto_bom)
379+
380+
if expected_action == "load_syft":
381+
assert syft_boms is not None
382+
assert hermeto_bom is None
383+
mock_open.assert_called_once()
384+
assert result == mock_syft_data
385+
mock_merge.assert_not_called()
386+
387+
elif expected_action == "load_hermeto":
388+
assert hermeto_bom is not None
389+
assert syft_boms is None
390+
mock_open.assert_called_once()
391+
assert result == mock_hermeto_data
392+
mock_merge.assert_not_called()
393+
394+
elif expected_action == "merge":
395+
mock_merge.assert_called_once_with(syft_boms, hermeto_bom)
396+
assert result == mock_merged_data
397+
mock_open.assert_not_called()

tests/test_cli.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ def test_parse_concurrency(value: str, expected: int | type) -> None:
3333
@pytest.mark.parametrize(
3434
["command", "success"],
3535
[
36-
(["generate", "oci-image"], False),
3736
(
3837
[
3938
"generate",

0 commit comments

Comments
 (0)