Support SBOM for depsolving in osbuild-depsolve-dnf
Extend osbuild-depsolve-dnf, to return JSON with SPDX SBOM that corresponds to the depsolved package set, if it has been requested. For now, only DNF4 is supported. Cover the new functionality with unit test. Signed-off-by: Tomáš Hozza <thozza@redhat.com>
This commit is contained in:
parent
65ef88687e
commit
1d8bd0f8a6
4 changed files with 105 additions and 15 deletions
|
|
@ -2,12 +2,14 @@ import os
|
|||
import os.path
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import Dict, List
|
||||
|
||||
import dnf
|
||||
import hawkey
|
||||
|
||||
from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys
|
||||
from osbuild.util.sbom.dnf import dnf_pkgset_to_sbom_pkgset
|
||||
from osbuild.util.sbom.spdx import bom_pkgset_to_spdx2_doc
|
||||
|
||||
|
||||
class DNF(SolverBase):
|
||||
|
|
@ -158,6 +160,17 @@ class DNF(SolverBase):
|
|||
def _timestamp_to_rfc3339(timestamp):
|
||||
return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
@staticmethod
|
||||
def _sbom_for_pkgset(pkgset: List[dnf.package.Package]) -> Dict:
|
||||
"""
|
||||
Create an SBOM document for the given package set.
|
||||
|
||||
For now, only SPDX v2 is supported.
|
||||
"""
|
||||
pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
|
||||
spdx_doc = bom_pkgset_to_spdx2_doc(pkgset)
|
||||
return spdx_doc.to_dict()
|
||||
|
||||
def dump(self):
|
||||
packages = []
|
||||
for package in self.base.sack.query().available():
|
||||
|
|
@ -230,7 +243,8 @@ class DNF(SolverBase):
|
|||
return packages
|
||||
|
||||
def depsolve(self, arguments):
|
||||
# # Return an empty list when 'transactions' key is missing or when it is None
|
||||
want_sbom = "sbom" in arguments
|
||||
# Return an empty list when 'transactions' key is missing or when it is None
|
||||
transactions = arguments.get("transactions") or []
|
||||
# collect repo IDs from the request so we know whether to translate gpg key paths
|
||||
request_repo_ids = set(repo["id"] for repo in arguments.get("repos", []))
|
||||
|
|
@ -310,4 +324,8 @@ class DNF(SolverBase):
|
|||
"packages": packages,
|
||||
"repos": repositories,
|
||||
}
|
||||
|
||||
if want_sbom:
|
||||
response["sbom"] = self._sbom_for_pkgset(last_transaction)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ from libdnf5.common import QueryCmp_CONTAINS as CONTAINS
|
|||
from libdnf5.common import QueryCmp_EQ as EQ
|
||||
from libdnf5.common import QueryCmp_GLOB as GLOB
|
||||
|
||||
from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys
|
||||
from osbuild.solver import (
|
||||
DepsolveError,
|
||||
MarkingError,
|
||||
RepoError,
|
||||
SolverBase,
|
||||
modify_rootdir_path,
|
||||
read_keys,
|
||||
)
|
||||
|
||||
|
||||
def remote_location(package, schemes=("http", "ftp", "file", "https")):
|
||||
|
|
|
|||
|
|
@ -145,11 +145,13 @@ def validate_request(request):
|
|||
"kind": "InvalidRequest",
|
||||
"reason": "no 'module_platform_id' specified"
|
||||
}
|
||||
|
||||
if not request.get("releasever"):
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "no 'releasever' specified"
|
||||
}
|
||||
|
||||
arguments = request.get("arguments")
|
||||
if not arguments:
|
||||
return {
|
||||
|
|
@ -157,6 +159,44 @@ def validate_request(request):
|
|||
"reason": "empty 'arguments'"
|
||||
}
|
||||
|
||||
sbom = request["arguments"].get("sbom")
|
||||
if sbom is not None:
|
||||
# NB: check the DNF5 flag here, instead of in the dnf5 module,
|
||||
# to consistently return this error message, even if there are other
|
||||
# potential errors in the request, such as broken repository.
|
||||
if config.get("use_dnf5", False):
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "SBOM support for DNF5 is not implemented"
|
||||
}
|
||||
|
||||
if command != "depsolve":
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "SBOM is only supported with 'depsolve' command"
|
||||
}
|
||||
if not isinstance(sbom, dict):
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "invalid 'sbom' value"
|
||||
}
|
||||
sbom_type = sbom.get("type")
|
||||
if sbom_type is None:
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "missing 'type' in 'sbom'"
|
||||
}
|
||||
if not isinstance(sbom_type, str):
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "invalid 'type' in 'sbom'"
|
||||
}
|
||||
if sbom_type != "spdx":
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
"reason": "Unsupported SBOM type"
|
||||
}
|
||||
|
||||
if not arguments.get("repos") and not arguments.get("root_dir"):
|
||||
return {
|
||||
"kind": "InvalidRequest",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from itertools import combinations
|
|||
from tempfile import TemporaryDirectory
|
||||
from typing import Tuple
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
REPO_PATHS = [
|
||||
|
|
@ -38,7 +39,7 @@ def assert_dnf():
|
|||
raise RuntimeError("Cannot import libdnf")
|
||||
|
||||
|
||||
def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata) -> Tuple[dict, int]:
|
||||
def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata, with_sbom=False) -> Tuple[dict, int]:
|
||||
req = {
|
||||
"command": "depsolve",
|
||||
"arch": ARCH,
|
||||
|
|
@ -53,12 +54,15 @@ def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata)
|
|||
}
|
||||
}
|
||||
|
||||
if with_sbom:
|
||||
req["arguments"]["sbom"] = {"type": "spdx"}
|
||||
|
||||
# If there is a config file, write it to a temporary file and pass it to the depsolver
|
||||
with TemporaryDirectory() as cfg_dir:
|
||||
env = None
|
||||
if dnf_config:
|
||||
cfg_file = pathlib.Path(cfg_dir) / "solver.json"
|
||||
cfg_file.write_text(dnf_config)
|
||||
json.dump(dnf_config, cfg_file.open("w"))
|
||||
env = {"OSBUILD_SOLVER_CONFIG": os.fspath(cfg_file)}
|
||||
|
||||
p = sp.run(["./tools/osbuild-depsolve-dnf"], input=json.dumps(req), env=env,
|
||||
|
|
@ -1240,13 +1244,15 @@ def config_combos(tmp_path, servers):
|
|||
yield repo_configs, os.fspath(root_dir), opt_metadata
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
@pytest.mark.parametrize("test_case", depsolve_test_cases, ids=tcase_idfn)
|
||||
@pytest.mark.parametrize("with_sbom", [False, True])
|
||||
@pytest.mark.parametrize("dnf_config, detect_fn", [
|
||||
(None, assert_dnf),
|
||||
('{"use_dnf5": false}', assert_dnf),
|
||||
('{"use_dnf5": true}', assert_dnf5),
|
||||
({}, assert_dnf),
|
||||
({"use_dnf5": False}, assert_dnf),
|
||||
({"use_dnf5": True}, assert_dnf5),
|
||||
], ids=["no-config", "dnf4", "dnf5"])
|
||||
def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
|
||||
def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom, test_case):
|
||||
try:
|
||||
detect_fn()
|
||||
except RuntimeError as e:
|
||||
|
|
@ -1260,7 +1266,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
|
|||
"error_pkg_not_in_enabled_repos",
|
||||
]
|
||||
|
||||
if dnf_config == '{"use_dnf5": true}' and test_case["id"] in dnf5_broken_test_cases:
|
||||
if dnf_config.get("use_dnf5", False) and test_case["id"] in dnf5_broken_test_cases:
|
||||
pytest.skip("This test case is known to be broken with dnf5")
|
||||
|
||||
transactions = test_case["transactions"]
|
||||
|
|
@ -1271,7 +1277,15 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
|
|||
|
||||
for repo_configs, root_dir, opt_metadata in config_combos(tmp_path, repo_servers_copy):
|
||||
with TemporaryDirectory() as cache_dir:
|
||||
res, exit_code = depsolve(transactions, repo_configs, root_dir, cache_dir, dnf_config, opt_metadata)
|
||||
res, exit_code = depsolve(transactions, repo_configs, root_dir,
|
||||
cache_dir, dnf_config, opt_metadata, with_sbom)
|
||||
|
||||
# NB: dnf5 implementation does not support SBOM yet
|
||||
if dnf_config.get("use_dnf5", False) and with_sbom:
|
||||
assert exit_code != 0
|
||||
assert res["kind"] == "InvalidRequest"
|
||||
assert res["reason"] == "SBOM support for DNF5 is not implemented"
|
||||
continue
|
||||
|
||||
if test_case.get("error", False):
|
||||
assert exit_code != 0
|
||||
|
|
@ -1285,6 +1299,20 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
|
|||
for repo in res["repos"].values():
|
||||
assert repo["gpgkeys"] == [TEST_KEY + repo["id"]]
|
||||
assert repo["sslverify"] is False
|
||||
if with_sbom:
|
||||
assert "sbom" in res
|
||||
|
||||
spdx_2_3_1_schema_file = './test/data/spdx/spdx-schema-v2.3.1.json'
|
||||
with open(spdx_2_3_1_schema_file, encoding="utf-8") as f:
|
||||
spdx_schema = json.load(f)
|
||||
validator = jsonschema.Draft4Validator
|
||||
validator.check_schema(spdx_schema)
|
||||
spdx_validator = validator(spdx_schema)
|
||||
spdx_validator.validate(res["sbom"])
|
||||
|
||||
assert {pkg["name"] for pkg in res["sbom"]["packages"]} == test_case["results"]["packages"]
|
||||
else:
|
||||
assert "sbom" not in res
|
||||
|
||||
# if opt_metadata includes 'filelists', then each repository 'repodata' must include a file that matches
|
||||
# *filelists*
|
||||
|
|
@ -1294,10 +1322,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
|
|||
else:
|
||||
assert n_filelist_files == 0
|
||||
|
||||
if dnf_config:
|
||||
use_dnf5 = json.loads(dnf_config)["use_dnf5"]
|
||||
else:
|
||||
use_dnf5 = False
|
||||
use_dnf5 = dnf_config.get("use_dnf5", False)
|
||||
if use_dnf5:
|
||||
assert res["solver"] == "dnf5"
|
||||
else:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue