Skip to content

Commit c19f7f1

Browse files
committed
feat(project CreateBom): write control file with attachment details
This can be used to check meta data of attachments before feeding the list into "bom downloadAttachments".
1 parent 45247e1 commit c19f7f1

4 files changed

Lines changed: 192 additions & 27 deletions

File tree

capycli/main/options.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ def register_options(self) -> None:
219219
help="create an mapping overview JSON file",
220220
)
221221

222+
self.parser.add_argument(
223+
"-ct",
224+
"--controlfile",
225+
dest="controlfile",
226+
help="control file for \"bom DownloadAttachments\" and \"project CreateReadme\"",
227+
)
228+
222229
self.parser.add_argument(
223230
"-mr",
224231
"--mapresult",

capycli/project/create_bom.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
import logging
1010
import sys
11-
from typing import Any, Dict, List
11+
from typing import Any, Dict, List, Tuple
12+
import json
1213

1314
from cyclonedx.model import ExternalReferenceType, HashAlgorithm
1415
from cyclonedx.model.bom import Bom
@@ -21,6 +22,7 @@
2122
from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport, SbomCreator
2223
from capycli.common.print import print_red, print_text, print_yellow
2324
from capycli.common.purl_utils import PurlUtils
25+
from capycli.common.script_support import ScriptSupport
2426
from capycli.main.result_codes import ResultCode
2527

2628
LOG = get_logger(__name__)
@@ -45,8 +47,9 @@ def get_clearing_state(self, proj: Dict[str, Any], href: str) -> str:
4547

4648
return ""
4749

48-
def create_project_bom(self, project: Dict[str, Any]) -> List[Component]:
50+
def create_project_bom(self, project: Dict[str, Any], create_controlfile: bool) -> Tuple[List, List]:
4951
bom: List[Component] = []
52+
details: List[Dist] = []
5053
if not self.client:
5154
print_red(" No client!")
5255
sys.exit(ResultCode.RESULT_ERROR_ACCESSING_SW360)
@@ -100,7 +103,6 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]:
100103
if "repository" in release_details and "url" in release_details["repository"]:
101104
CycloneDxSupport.set_ext_ref(rel_item, ExternalReferenceType.VCS, comment="",
102105
value=release_details["repository"]["url"])
103-
104106
attachments = self.get_release_attachments(release_details)
105107
for attachment in attachments:
106108
at_type = attachment["attachmentType"]
@@ -111,8 +113,23 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]:
111113
ext_ref_type = ExternalReferenceType.DISTRIBUTION
112114
else:
113115
ext_ref_type = ExternalReferenceType.OTHER
114-
comment += (", sw360Id: "
115-
+ self.client.get_id_from_href(attachment["_links"]["self"]["href"]))
116+
if create_controlfile:
117+
at_data = self.client.get_attachment_by_url(attachment["_links"]["self"]["href"])
118+
119+
at_details = {
120+
"ComponentName": " ".join((release["name"], release["version"])),
121+
"Sw360Id": sw360_id,
122+
"Sw360AttachmentId": self.client.get_id_from_href(attachment["_links"]["self"]["href"])}
123+
for key in ("createdBy", "createdTeam", "createdOn", "createdComment", "checkStatus",
124+
"checkedBy", "checkedTeam", "checkedOn", "checkedComment"):
125+
if key in at_data and at_data[key]:
126+
at_details[key[0].upper() + key[1:]] = at_data[key]
127+
128+
if at_type == "COMPONENT_LICENSE_INFO_XML":
129+
at_details["CliFile"] = attachment["filename"]
130+
elif at_type == "CLEARING_REPORT":
131+
at_details["ReportFile"] = attachment["filename"]
132+
details.append(at_details)
116133
CycloneDxSupport.set_ext_ref(rel_item, ext_ref_type,
117134
comment, attachment["filename"],
118135
HashAlgorithm.SHA_1, attachment.get("sha1"))
@@ -136,9 +153,9 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]:
136153

137154
# sub-projects are not handled at the moment
138155

139-
return bom
156+
return bom, details
140157

141-
def create_project_cdx_bom(self, project_id: str) -> Bom:
158+
def create_project_cdx_bom(self, project_id: str, create_controlfile:bool) -> Tuple[Bom, Dict]:
142159
if not self.client:
143160
print_red(" No client!")
144161
sys.exit(ResultCode.RESULT_ERROR_ACCESSING_SW360)
@@ -154,14 +171,19 @@ def create_project_cdx_bom(self, project_id: str) -> Bom:
154171

155172
print_text(" Project name: " + project["name"] + ", " + project["version"])
156173

157-
cdx_components = self.create_project_bom(project)
174+
cdx_components, control_components = self.create_project_bom(project, create_controlfile)
158175

159176
creator = SbomCreator()
160177
sbom = creator.create(cdx_components, addlicense=True, addprofile=True, addtools=True,
161178
name=project.get("name", ""), version=project.get("version", ""),
162179
description=project.get("description", ""), addprojectdependencies=True)
163180

164-
return sbom
181+
controlfile = {
182+
"ProjectName": ScriptSupport.get_full_name_from_dict(project, "name", "version"),
183+
"Components": control_components
184+
}
185+
186+
return sbom, controlfile
165187

166188
def show_command_help(self) -> None:
167189
print("\nusage: CaPyCli project createbom [options]")
@@ -174,6 +196,7 @@ def show_command_help(self) -> None:
174196
-name name of the project, component or release
175197
-version version of the project, component or release
176198
-o OUTPUTFILE output file to write to
199+
-ct CONTROLFILE write control file for "bom DownloadAttachments" and "project CreateReadme"
177200
""")
178201

179202
print()
@@ -220,7 +243,11 @@ def run(self, args: Any) -> None:
220243
sys.exit(ResultCode.RESULT_COMMAND_ERROR)
221244

222245
if pid:
223-
bom = self.create_project_cdx_bom(pid)
246+
bom, controlfile = self.create_project_cdx_bom(pid, args.controlfile)
224247
CaPyCliBom.write_sbom(bom, args.outputfile)
248+
249+
if args.controlfile:
250+
with open(args.controlfile, "w") as outfile:
251+
json.dump(controlfile, outfile, indent=2)
225252
else:
226253
print_yellow(" No matching project found")

tests/fixtures/sbom_for_download.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
},
9292
{
9393
"url": "CLIXML_certifi-2022.12.7.xml",
94-
"comment": "component license information (local copy), sw360Id: 794446",
94+
"comment": "component license information (local copy)",
9595
"type": "other",
9696
"hashes": [
9797
{
@@ -102,7 +102,7 @@
102102
},
103103
{
104104
"url": "certifi-2022.12.7_clearing_report.docx",
105-
"comment": "clearing report (local copy), sw360Id: 63b368",
105+
"comment": "clearing report (local copy)",
106106
"type": "other",
107107
"hashes": [
108108
{

tests/test_create_bom.py

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def test_project_not_found(self) -> None:
103103
args.verbose = True
104104
args.id = "34ef5c5452014c52aa9ce4bc180624d8"
105105
args.outputfile = self.OUTPUTFILE
106+
args.controlfile = None
106107

107108
self.add_login_response()
108109

@@ -150,27 +151,21 @@ def test_create_bom_multiple_purls(self, capsys: Any) -> None:
150151
adding_headers={"Authorization": "Token " + self.MYTOKEN},
151152
)
152153

153-
cdx_components = sut.create_project_bom(self.get_project_for_test())
154+
cdx_components, _ = sut.create_project_bom(self.get_project_for_test(),
155+
create_controlfile=False)
154156
captured = capsys.readouterr()
155157

156158
assert "Multiple purls added" in captured.out
157159
assert cdx_components[0].purl is not None
158160
if cdx_components[0].purl:
159161
assert cdx_components[0].purl.to_string() == "pkg:deb/debian/cli-support%401.3-1%20pkg:pypi/cli-support@1.3"
160162

161-
@responses.activate
162-
def test_project_by_id(self) -> None:
163-
sut = CreateBom()
164-
165-
self.add_login_response()
166-
sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
167-
163+
def add_project_releases_responses(self):
168164
# the project
169-
project = self.get_project_for_test()
170165
responses.add(
171166
responses.GET,
172167
url=self.MYURL + "resource/api/projects/p001",
173-
json=project,
168+
json=self.get_project_for_test(),
174169
status=200,
175170
content_type="application/json",
176171
adding_headers={"Authorization": "Token " + self.MYTOKEN},
@@ -197,7 +192,7 @@ def test_project_by_id(self) -> None:
197192
"attachmentType": "SOURCE_SELF",
198193
"_links": {
199194
"self": {
200-
"href": "https://my.server.com/resource/api/attachments/r002a002"
195+
"href": "https://my.server.com/resource/api/attachments/r002a003"
201196
}
202197
}
203198
})
@@ -207,7 +202,7 @@ def test_project_by_id(self) -> None:
207202
"attachmentType": "CLEARING_REPORT",
208203
"_links": {
209204
"self": {
210-
"href": "https://my.server.com/resource/api/attachments/r002a003"
205+
"href": "https://my.server.com/resource/api/attachments/r002a004"
211206
}
212207
}
213208
})
@@ -220,8 +215,19 @@ def test_project_by_id(self) -> None:
220215
content_type="application/json",
221216
adding_headers={"Authorization": "Token " + self.MYTOKEN},
222217
)
218+
return release
223219

224-
cdx_bom = sut.create_project_cdx_bom("p001")
220+
@responses.activate
221+
def test_project_by_id(self) -> None:
222+
sut = CreateBom()
223+
224+
self.add_login_response()
225+
sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
226+
227+
release = self.add_project_releases_responses()
228+
project = self.get_project_for_test()
229+
230+
cdx_bom, _ = sut.create_project_cdx_bom("p001", create_controlfile=False)
225231
cx_comp = cdx_bom.components[0]
226232
assert cx_comp.purl.to_string() == release["externalIds"]["package-url"]
227233

@@ -242,15 +248,15 @@ def test_project_by_id(self) -> None:
242248
assert len(ext_refs) == 1
243249
assert str(ext_refs[0].url) == release["_embedded"]["sw360:attachments"][1]["filename"]
244250
assert ext_refs[0].type == ExternalReferenceType.OTHER
245-
assert ext_refs[0].comment, CaPyCliBom.CLI_FILE_COMMENT + " == sw360Id: r002a002"
251+
assert ext_refs[0].comment == CaPyCliBom.CLI_FILE_COMMENT
246252
assert ext_refs[0].hashes[0].alg == "SHA-1"
247253
assert ext_refs[0].hashes[0].content == release["_embedded"]["sw360:attachments"][1]["sha1"]
248254

249255
ext_refs = [e for e in cx_comp.external_references
250256
if e.comment and e.comment.startswith(CaPyCliBom.CRT_FILE_COMMENT)]
251257
assert len(ext_refs) == 1
252258
assert str(ext_refs[0].url) == release["_embedded"]["sw360:attachments"][3]["filename"]
253-
assert ext_refs[0].comment, CaPyCliBom.CRT_FILE_COMMENT + " == sw360Id: r002a003"
259+
assert ext_refs[0].comment == CaPyCliBom.CRT_FILE_COMMENT
254260
assert ext_refs[0].type == ExternalReferenceType.OTHER
255261
assert ext_refs[0].hashes[0].alg == "SHA-1"
256262
assert ext_refs[0].hashes[0].content == release["_embedded"]["sw360:attachments"][3]["sha1"]
@@ -265,6 +271,71 @@ def test_project_by_id(self) -> None:
265271
assert cdx_bom.metadata.component.version == project["version"]
266272
assert cdx_bom.metadata.component.description == project["description"]
267273

274+
@responses.activate
275+
def test_project_by_id_controlfile(self):
276+
sut = CreateBom()
277+
self.add_login_response()
278+
sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
279+
280+
self.add_project_releases_responses()
281+
282+
# attachment info
283+
responses.add(
284+
method=responses.GET,
285+
url=self.MYURL + "resource/api/attachments/r001a001",
286+
body="""
287+
{
288+
"filename": "CLIXML_wheel-0.38.4.xml",
289+
"sha1": "ccd9f1ed2f59c46ff3f0139c05bfd76f83fd9851",
290+
"attachmentType": "COMPONENT_LICENSE_INFO_XML"
291+
}""",
292+
status=200,
293+
content_type="application/json",
294+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
295+
)
296+
responses.add(
297+
method=responses.GET,
298+
url=self.MYURL + "resource/api/attachments/r002a002",
299+
body="""
300+
{
301+
"filename": "CLIXML_clipython-1.3.0.xml",
302+
"sha1": "dd4c38387c6811dba67d837af7742d84e61e20de",
303+
"attachmentType": "COMPONENT_LICENSE_INFO_XML",
304+
"checkedBy": "user2@siemens.com",
305+
"checkStatus": "ACCEPTED",
306+
"createdBy": "user1@siemens.com"
307+
}""",
308+
status=200,
309+
content_type="application/json",
310+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
311+
)
312+
responses.add(
313+
method=responses.GET,
314+
url=self.MYURL + "resource/api/attachments/r002a004",
315+
body="""
316+
{
317+
"filename": "clipython-1.3.0.docx",
318+
"sha1": "f0d8f2ddd017bdeaecbaec72ff76a6c0a045ec66",
319+
"attachmentType": "CLEARING_REPORT"
320+
321+
}""",
322+
status=200,
323+
content_type="application/json",
324+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
325+
)
326+
327+
_, controlfile = sut.create_project_cdx_bom("p001", create_controlfile=True)
328+
assert controlfile['ProjectName'] == 'CaPyCLI, 1.9.0'
329+
assert controlfile['Components'][0]['ComponentName'] == 'cli-support 1.3'
330+
assert controlfile['Components'][0]['Sw360Id'] == 'r002'
331+
assert controlfile['Components'][0]['Sw360AttachmentId'] == 'r002a002'
332+
assert controlfile['Components'][0]['CliFile'] == 'CLIXML_clipython-1.3.0.xml'
333+
assert controlfile['Components'][0]['CheckedBy'] == 'user2@siemens.com'
334+
assert controlfile['Components'][0]['CheckStatus'] == 'ACCEPTED'
335+
assert controlfile['Components'][0]['CreatedBy'] == 'user1@siemens.com'
336+
337+
assert controlfile['Components'][1]['ReportFile'] == 'clipython-1.3.0.docx'
338+
268339
@responses.activate
269340
def test_project_show_by_name(self) -> None:
270341
sut = CreateBom()
@@ -280,6 +351,7 @@ def test_project_show_by_name(self) -> None:
280351
args.name = "CaPyCLI"
281352
args.version = "1.9.0"
282353
args.outputfile = self.OUTPUTFILE
354+
args.controlfile = None
283355

284356
self.add_login_response()
285357

@@ -363,6 +435,65 @@ def test_project_show_by_name(self) -> None:
363435

364436
self.delete_file(self.OUTPUTFILE)
365437

438+
@responses.activate
439+
def test_create_project_bom_release_error(self):
440+
sut = CreateBom()
441+
442+
self.add_login_response()
443+
sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
444+
445+
responses.add(
446+
responses.GET,
447+
url=self.MYURL + "resource/api/releases/r001",
448+
status=404,
449+
content_type="application/json",
450+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
451+
)
452+
responses.add(
453+
responses.GET,
454+
url=self.MYURL + "resource/api/releases/r002",
455+
json=self.get_release_cli_for_test(),
456+
status=200,
457+
content_type="application/json",
458+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
459+
)
460+
with pytest.raises(SystemExit):
461+
bom, _ = sut.create_project_bom(self.get_project_for_test(), create_controlfile=False)
462+
463+
@responses.activate
464+
def test_create_project_bom_controlfile_attachment_error(self):
465+
sut = CreateBom()
466+
467+
self.add_login_response()
468+
sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
469+
470+
responses.add(
471+
responses.GET,
472+
url=self.MYURL + "resource/api/releases/r001",
473+
json=self.get_release_wheel_for_test(),
474+
status=200,
475+
content_type="application/json",
476+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
477+
)
478+
responses.add(
479+
responses.GET,
480+
url=self.MYURL + "resource/api/releases/r002",
481+
json=self.get_release_cli_for_test(),
482+
status=200,
483+
content_type="application/json",
484+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
485+
)
486+
responses.add(
487+
method=responses.GET,
488+
url=self.MYURL + "resource/api/attachments/r002a002",
489+
status=404,
490+
content_type="application/json",
491+
adding_headers={"Authorization": "Token " + self.MYTOKEN},
492+
)
493+
494+
with pytest.raises(SystemExit):
495+
bom, _ = sut.create_project_bom(self.get_project_for_test(), create_controlfile=True)
496+
366497

367498
if __name__ == "__main__":
368499
APP = TestCreateBom()

0 commit comments

Comments
 (0)