Skip to content

Commit 0e62190

Browse files
Export Test Run artifacts in SPDX traceability (#308)
Include on-disk Test Run artifacts as SPDX Files (purpose evidence) with content MD5 and ExternalIdentifiers, linked from each Test Run via hasOutput and hasEvidence. Share artifact path/listing helpers with the Test Run API and document the new graph nodes and relationships. Signed-off-by: Luigi Pellecchia <pellecchia.luigi@gmail.com>
1 parent 7e36e31 commit 0e62190

4 files changed

Lines changed: 156 additions & 15 deletions

File tree

api/api.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,15 @@
8383
get_html_email_body_from_template,
8484
get_mapping_comments,
8585
get_safe_str,
86+
get_test_run_artifacts_dir,
8687
get_user_config_folder_path,
8788
get_user_html_folder_path,
8889
get_user_pdf_folder_path,
8990
get_user_traceability_scanner_config,
9091
is_safe_local_user_file_path,
9192
is_safe_user_path,
9293
is_testing_enabled_by_env,
94+
list_test_run_artifacts,
9395
load_settings,
9496
parse_int,
9597
read_file,
@@ -10924,13 +10926,8 @@ def get(
1092410926
else:
1092510927
log_exec = "File not found."
1092610928

10927-
# List files in the TMT_PLAN_DATA dir
10928-
artifacts = []
10929-
if os.path.exists(os.path.join(TEST_RUNS_BASE_DIR, run.uid, "api", "tmt-plan", "data")):
10930-
artifacts = os.listdir(os.path.join(TEST_RUNS_BASE_DIR, run.uid, "api", "tmt-plan", "data"))
10931-
1093210929
ret = run.as_dict()
10933-
ret["artifacts"] = artifacts
10930+
ret["artifacts"] = list_test_run_artifacts(run.uid)
1093410931
ret["log_exec"] = log_exec
1093510932
api_response.set_data(ret)
1093610933
return api_response.return_ok()
@@ -10974,9 +10971,8 @@ def get(
1097410971
api_response.set_message(f"Unable to find the test run id {request_data['id']}")
1097510972
return api_response.return_not_found()
1097610973

10977-
# List files in the TMT_PLAN_DATA dir
10978-
artifacts_path = os.path.join(TEST_RUNS_BASE_DIR, run.uid, "api", "tmt-plan", "data")
10979-
artifacts = os.listdir(artifacts_path)
10974+
artifacts_path = get_test_run_artifacts_dir(run.uid)
10975+
artifacts = list_test_run_artifacts(run.uid)
1098010976
if request_data["artifact"] not in artifacts:
1098110977
api_response.set_message(f"Unable to find the artifact {request_data['artifact']}")
1098210978
return api_response.return_not_found()
@@ -11022,8 +11018,8 @@ def get(
1102211018
api_response.set_message(f"Unable to find the test run id {request_data['id']}")
1102311019
return api_response.return_not_found()
1102411020

11025-
artifacts_path = os.path.join(TEST_RUNS_BASE_DIR, run.uid, "api", "tmt-plan", "data")
11026-
artifacts = os.listdir(artifacts_path)
11021+
artifacts_path = get_test_run_artifacts_dir(run.uid)
11022+
artifacts = list_test_run_artifacts(run.uid)
1102711023
if request_data["artifact"] not in artifacts:
1102811024
api_response.set_message(f"Unable to find the artifact {request_data['artifact']}")
1102911025
return api_response.return_not_found()

api/api_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,3 +857,31 @@ def parse_comma_separated_list(value=None):
857857
def is_http_url(value: str) -> bool:
858858
"""Return True if value looks like an http(s) URL."""
859859
return bool(value) and value.lower().startswith(("http://", "https://"))
860+
861+
862+
def get_test_run_artifacts_dir(test_run_uid: str) -> str:
863+
"""Return the on-disk directory where BASIL stores Test Run artifacts."""
864+
# Same default as TEST_RUNS_BASE_DIR in api.py / testrun_tmt.py
865+
test_runs_base_dir = os.getenv("TEST_RUNS_BASE_DIR", "/var/test-runs")
866+
return os.path.join(test_runs_base_dir, test_run_uid, "api", "tmt-plan", "data")
867+
868+
869+
def list_test_run_artifacts(test_run_uid: str) -> list:
870+
"""List artifact file names for a Test Run (sorted, files only).
871+
872+
Returns an empty list when the artifacts directory is missing or
873+
unreadable. Directories inside the artifacts path are ignored.
874+
"""
875+
artifacts_dir = get_test_run_artifacts_dir(test_run_uid)
876+
if not os.path.isdir(artifacts_dir):
877+
return []
878+
try:
879+
names = [
880+
name
881+
for name in os.listdir(artifacts_dir)
882+
if os.path.isfile(os.path.join(artifacts_dir, name))
883+
]
884+
except OSError as e:
885+
logger.warning(f"Unable to list artifacts for test run {test_run_uid}: {e}")
886+
return []
887+
return sorted(names)

api/spdx_manager.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
currentdir = os.path.dirname(os.path.realpath(__file__))
1515
sys.path.insert(1, os.path.dirname(currentdir))
1616

17-
from api.api_utils import is_http_url, parse_comma_separated_list # noqa E402
17+
from api.api_utils import ( # noqa E402
18+
get_test_run_artifacts_dir,
19+
is_http_url,
20+
list_test_run_artifacts,
21+
parse_comma_separated_list,
22+
)
1823
from db.db_orm import DbInterface # noqa E402
1924
from db.models.api import ApiModel # noqa E402
2025
from db.models.api_document import ApiDocumentModel # noqa E402
@@ -76,6 +81,9 @@ class SPDXExternalIdentifier:
7681
``identifier`` and, when the value is an http(s) URL, also set
7782
``identifierLocator``.
7883
84+
Test Run artifacts use:
85+
basil:test_runs:<id>:artifact:<filename>
86+
7987
Example:
8088
identifier = "basil:sw_requirements:42"
8189
comment = "BASIL Software Requirement 'My Title' with ID 42"
@@ -1135,6 +1143,7 @@ def addJustification(self, justification: JustificationModel = None, dbsession=N
11351143
def addTestRuns(self, spdx_tc: SPDXFile = None, mapping_to: str = "", mapping_id: int = 0, dbsession=None):
11361144
if not self.include_test_runs:
11371145
logger.warning("Skip Test Runs as per export configuration")
1146+
return
11381147

11391148
added_test_runs = []
11401149
test_runs_query = (
@@ -1199,6 +1208,7 @@ def addTestRun(self, test_run: TestRunModel = None, dbsession=None):
11991208
self.add_to_sbom(tr_file)
12001209
self.add_to_sbom(tr_annotation)
12011210
self.addTestRunBugAndFixOutputs(spdx_tr=tr_file, test_run=test_run, creation_info=creation_info)
1211+
self.addTestRunArtifacts(spdx_tr=tr_file, test_run=test_run, creation_info=creation_info)
12021212
return tr_file
12031213

12041214
def addTestRunBugOrFix(
@@ -1275,6 +1285,101 @@ def addTestRunBugAndFixOutputs(
12751285
relationship_type=SpdxRelationshipType.HAS_OUTPUT,
12761286
)
12771287

1288+
def addTestRunArtifact(
1289+
self,
1290+
test_run: TestRunModel = None,
1291+
artifact_name: str = "",
1292+
index: int = 1,
1293+
creation_info: SPDXCreationInfo = None,
1294+
):
1295+
"""Create an SPDX File for one Test Run artifact on disk.
1296+
1297+
Artifacts live under ``TEST_RUNS_BASE_DIR/<uid>/api/tmt-plan/data/``.
1298+
``verifiedUsing`` prefers an MD5 of the file contents when readable;
1299+
otherwise a metadata hash is used.
1300+
"""
1301+
artifacts_dir = get_test_run_artifacts_dir(test_run.uid)
1302+
artifact_path = os.path.join(artifacts_dir, artifact_name)
1303+
spdx_id = f"spdx:file:basil:test-run:{test_run.id}:artifact:{index}"
1304+
1305+
content_hash = None
1306+
if os.path.isfile(artifact_path):
1307+
try:
1308+
dhash = hashlib.md5()
1309+
with open(artifact_path, "rb") as artifact_file:
1310+
for chunk in iter(lambda: artifact_file.read(1024 * 1024), b""):
1311+
dhash.update(chunk)
1312+
content_hash = SPDXMD5Hash(hash_value=dhash.hexdigest())
1313+
except OSError as e:
1314+
logger.warning(f"Unable to hash artifact {artifact_path}: {e}")
1315+
1316+
if content_hash is None:
1317+
content_hash = self.make_hash_object(
1318+
data_dict={
1319+
"artifact_name": artifact_name,
1320+
"test_run_id": test_run.id,
1321+
"index": index,
1322+
}
1323+
)
1324+
1325+
artifact_file = SPDXFile(
1326+
spdx_id=spdx_id,
1327+
name=artifact_name,
1328+
comment="BASIL Artifact",
1329+
description=f"Artifact '{artifact_name}' from BASIL Test Run {test_run.id}",
1330+
purpose="evidence",
1331+
copyright_text="",
1332+
verified_using=[content_hash],
1333+
external_identifiers=[
1334+
SPDXExternalIdentifier(
1335+
identifier=f"basil:{test_run.__tablename__}:{test_run.id}:artifact:{artifact_name}",
1336+
comment=f"BASIL Test Run {test_run.id} Artifact '{artifact_name}'",
1337+
)
1338+
],
1339+
creation_info=creation_info,
1340+
)
1341+
self.add_to_sbom(artifact_file)
1342+
return artifact_file
1343+
1344+
def addTestRunArtifacts(
1345+
self,
1346+
spdx_tr: SPDXFile = None,
1347+
test_run: TestRunModel = None,
1348+
creation_info: SPDXCreationInfo = None,
1349+
):
1350+
"""Emit SPDX Files for Test Run artifacts and link them 1-to-many.
1351+
1352+
Each on-disk artifact becomes an SPDX File (purpose ``evidence``).
1353+
The Test Run is linked to all artifacts with both ``hasOutput`` and
1354+
``hasEvidence`` relationships (one relationship of each type covering
1355+
the full artifact list).
1356+
"""
1357+
artifact_names = list_test_run_artifacts(test_run.uid)
1358+
if not artifact_names:
1359+
return
1360+
1361+
added_artifacts = []
1362+
for index, artifact_name in enumerate(artifact_names, start=1):
1363+
added_artifacts.append(
1364+
self.addTestRunArtifact(
1365+
test_run=test_run,
1366+
artifact_name=artifact_name,
1367+
index=index,
1368+
creation_info=creation_info,
1369+
)
1370+
)
1371+
1372+
self.addRelationship(
1373+
from_element=spdx_tr,
1374+
to=added_artifacts,
1375+
relationship_type=SpdxRelationshipType.HAS_OUTPUT,
1376+
)
1377+
self.addRelationship(
1378+
from_element=spdx_tr,
1379+
to=added_artifacts,
1380+
relationship_type=SpdxRelationshipType.HAS_EVIDENCE,
1381+
)
1382+
12781383
def addDocumentsNestedElements(
12791384
self,
12801385
api: ApiModel = None,
@@ -1629,6 +1734,7 @@ def get_file_node_color(node):
16291734
"test run": "purple",
16301735
"bug": "salmon",
16311736
"fix": "olivedrab",
1737+
"artifact": "khaki",
16321738
}
16331739

16341740
if hasattr(node, "comment"):
@@ -1744,6 +1850,7 @@ def add_edge(src, dst, label):
17441850
legend.node("test_run", label="Test Run", shape="box", style="filled", fillcolor="purple")
17451851
legend.node("bug", label="Bug", shape="box", style="filled", fillcolor="salmon")
17461852
legend.node("fix", label="Fix", shape="box", style="filled", fillcolor="olivedrab")
1853+
legend.node("artifact", label="Artifact", shape="box", style="filled", fillcolor="khaki")
17471854

17481855
# Stack legend nodes vertically
17491856
legend.edge("library", "software_component", style="invis", weight="100")
@@ -1757,6 +1864,7 @@ def add_edge(src, dst, label):
17571864
legend.edge("test_case", "test_run", style="invis", weight="100")
17581865
legend.edge("test_run", "bug", style="invis", weight="100")
17591866
legend.edge("bug", "fix", style="invis", weight="100")
1867+
legend.edge("fix", "artifact", style="invis", weight="100")
17601868

17611869
dot.subgraph(legend)
17621870

docs/source/traceability_export.rst

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ BASIL exports the following types of traceability relationships:
7878
- **hasSpecification**: e.g.:API Reference document Snippet <- Test Specifications or Sw Requirement <- Test Specification
7979
- **hasTest**: e.g.: Test Specification <- Test Cases or Sw Requirement <- Test Case ...
8080
- **generates**: e.g.: Test Cases <- Test Runs
81-
- **hasOutput**: e.g.: Test Run <- Bug or Fix (tracker URL / reference)
82-
- **hasEvidence**: e.g.: API Reference document Snippet <- Justification
81+
- **hasOutput**: e.g.: Test Run <- Bug or Fix (tracker URL / reference); Test Run <- Artifacts
82+
- **hasEvidence**: e.g.: API Reference document Snippet <- Justification; Test Run <- Artifacts
8383
- **contains**: e.g.: Library <- API
8484

8585
Each relationship includes:
@@ -104,7 +104,7 @@ Graph Structure
104104
~~~~~~~~~~~~~~~
105105

106106
The generated graph shows:
107-
- Work items as nodes (requirements, specifications, test cases, test runs, bugs, fixes)
107+
- Work items as nodes (requirements, specifications, test cases, test runs, bugs, fixes, artifacts)
108108
- Relationships as directed edges
109109
- Color coding for different work item types
110110
- Hierarchical layout from requirements to test runs
@@ -127,6 +127,15 @@ Test Run Integration
127127
``identifierLocator``)
128128
- Each Test Run is linked to each Bug/Fix with a dedicated 1-to-1
129129
``hasOutput`` relationship
130+
- Artifacts stored under the Test Run artifacts directory
131+
(``TEST_RUNS_BASE_DIR/<uid>/api/tmt-plan/data/``) are each exported as an
132+
SPDX File with ``purpose="evidence"``
133+
- Each artifact File carries an ``ExternalIdentifier`` of the form
134+
``basil:test_runs:<id>:artifact:<filename>`` and a content MD5 when the
135+
file is readable
136+
- Each Test Run is linked to its artifacts with 1-to-many ``hasOutput`` and
137+
``hasEvidence`` relationships (both relationship types target the same
138+
artifact list)
130139

131140
Test Run Limitations
132141
~~~~~~~~~~~~~~~~~~~~

0 commit comments

Comments
 (0)