Skip to content

Commit badc0d6

Browse files
authored
Add a "Load Packages from SBOMs" Product action in the REST API #59 (#62)
* Add a "Load Packages from SBOMs" Product action in the REST API #59 Signed-off-by: tdruez <tdruez@nexb.com> * Add a "Import scan results" Product action in the REST API #59 Signed-off-by: tdruez <tdruez@nexb.com> * Add "Pull data from a ScanCode.io" Product action in the REST API #59 Signed-off-by: tdruez <tdruez@nexb.com> --------- Signed-off-by: tdruez <tdruez@nexb.com>
1 parent aca7787 commit badc0d6

File tree

7 files changed

+334
-65
lines changed

7 files changed

+334
-65
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Release notes
2424
- Add dark theme support in UI.
2525
https://github.com/nexB/dejacode/issues/25
2626

27+
- Add "Load Packages from SBOMs", "Import scan results", and
28+
"Pull ScanCode.io project data" feature as Product action in the REST API.
29+
https://github.com/nexB/dejacode/issues/59
30+
31+
- Refactor the "Import manifest" feature as "Load SBOMs".
32+
https://github.com/nexB/dejacode/issues/61
33+
2734
### Version 5.0.1
2835

2936
- Improve the stability of the "Check for new Package versions" feature.

product_portfolio/api.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
import django_filters
1212
from rest_framework import permissions
1313
from rest_framework import serializers
14+
from rest_framework import status
15+
from rest_framework.decorators import action
1416
from rest_framework.permissions import SAFE_METHODS
17+
from rest_framework.response import Response
1518

1619
from component_catalog.api import KeywordsField
1720
from component_catalog.api import PackageEmbeddedSerializer
@@ -31,6 +34,9 @@
3134
from dje.filters import NameVersionFilter
3235
from dje.permissions import assign_all_object_permissions
3336
from product_portfolio.filters import ComponentCompletenessAPIFilter
37+
from product_portfolio.forms import ImportFromScanForm
38+
from product_portfolio.forms import LoadSBOMsForm
39+
from product_portfolio.forms import PullProjectDataForm
3440
from product_portfolio.models import CodebaseResource
3541
from product_portfolio.models import Product
3642
from product_portfolio.models import ProductComponent
@@ -191,6 +197,57 @@ class Meta:
191197
)
192198

193199

200+
class LoadSBOMsFormSerializer(serializers.Serializer):
201+
"""Serializer equivalent of LoadSBOMsForm, used for API documentation."""
202+
203+
input_file = serializers.FileField(
204+
required=True,
205+
help_text=LoadSBOMsForm.base_fields["input_file"].label,
206+
)
207+
update_existing_packages = serializers.BooleanField(
208+
required=False,
209+
default=False,
210+
help_text=LoadSBOMsForm.base_fields["update_existing_packages"].help_text,
211+
)
212+
scan_all_packages = serializers.BooleanField(
213+
required=False,
214+
default=False,
215+
help_text=LoadSBOMsForm.base_fields["scan_all_packages"].help_text,
216+
)
217+
218+
219+
class ImportFromScanSerializer(serializers.Serializer):
220+
"""Serializer equivalent of ImportFromScanForm, used for API documentation."""
221+
222+
upload_file = serializers.FileField(
223+
required=True,
224+
)
225+
create_codebase_resources = serializers.BooleanField(
226+
required=False,
227+
default=False,
228+
help_text=ImportFromScanForm.base_fields["create_codebase_resources"].help_text,
229+
)
230+
stop_on_error = serializers.BooleanField(
231+
required=False,
232+
default=False,
233+
help_text=ImportFromScanForm.base_fields["stop_on_error"].help_text,
234+
)
235+
236+
237+
class PullProjectDataSerializer(serializers.Serializer):
238+
"""Serializer equivalent of PullProjectDataForm, used for API documentation."""
239+
240+
project_name_or_uuid = serializers.CharField(
241+
required=True,
242+
help_text=PullProjectDataForm.base_fields["project_name_or_uuid"].label,
243+
)
244+
update_existing_packages = serializers.BooleanField(
245+
required=False,
246+
default=False,
247+
help_text=PullProjectDataForm.base_fields["update_existing_packages"].help_text,
248+
)
249+
250+
194251
class ProductViewSet(CreateRetrieveUpdateListViewSet):
195252
queryset = Product.objects.none()
196253
serializer_class = ProductSerializer
@@ -240,6 +297,72 @@ def perform_create(self, serializer):
240297
super().perform_create(serializer)
241298
assign_all_object_permissions(self.request.user, serializer.instance)
242299

300+
@action(detail=True, methods=["post"], serializer_class=LoadSBOMsFormSerializer)
301+
def load_sboms(self, request, *args, **kwargs):
302+
"""
303+
Load Packages from SBOMs.
304+
305+
DejaCode supports the following SBOM formats:
306+
* CycloneDX BOM as JSON bom.json and .cdx.json,
307+
* SPDX document as JSON .spdx.json,
308+
* AboutCode .ABOUT files,
309+
310+
Multiple SBOMs: You can provide multiple SBOMs by packaging them into a zip
311+
archive. DejaCode will handle and process them accordingly.
312+
"""
313+
product = self.get_object()
314+
315+
form = LoadSBOMsForm(data=request.POST, files=request.FILES)
316+
if not form.is_valid():
317+
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
318+
319+
form.submit(product=product, user=request.user)
320+
return Response({"status": "SBOM file submitted to ScanCode.io for inspection."})
321+
322+
@action(detail=True, methods=["post"], serializer_class=ImportFromScanSerializer)
323+
def import_from_scan(self, request, *args, **kwargs):
324+
"""
325+
Import the scan results in the Product.
326+
327+
Upload a ScanCode.io or ScanCode-toolkit JSON output file.
328+
"""
329+
product = self.get_object()
330+
331+
form = ImportFromScanForm(user=request.user, data=request.POST, files=request.FILES)
332+
if not form.is_valid():
333+
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
334+
335+
try:
336+
warnings, created_counts = form.save(product=product)
337+
except ValidationError as error:
338+
return Response(error.messages, status=status.HTTP_400_BAD_REQUEST)
339+
340+
if not created_counts:
341+
msg = "Nothing imported."
342+
else:
343+
msg = "Imported from Scan: "
344+
msg += ", ".join([f"{value} {key}" for key, value in created_counts.items()])
345+
return Response({"status": msg})
346+
347+
@action(detail=True, methods=["post"], serializer_class=PullProjectDataSerializer)
348+
def pull_scancodeio_project_data(self, request, *args, **kwargs):
349+
"""
350+
Pull data from a ScanCode.io Project to import all its Discovered Packages.
351+
Imported Packages will be assigned to this Product.
352+
"""
353+
product = self.get_object()
354+
355+
form = PullProjectDataForm(data=request.POST)
356+
if not form.is_valid():
357+
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
358+
359+
try:
360+
form.submit(product=product, user=request.user)
361+
except ValidationError as error:
362+
return Response(error.messages, status=status.HTTP_400_BAD_REQUEST)
363+
364+
return Response({"status": "Packages import from ScanCode.io in progress..."})
365+
243366

244367
class BaseProductRelationSerializer(ValidateLicenseExpressionMixin, DataspacedSerializer):
245368
product = NameVersionHyperlinkedRelatedField(

product_portfolio/forms.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
#
88

99
from django import forms
10+
from django.core.exceptions import ValidationError
11+
from django.db import transaction
1012
from django.forms import BaseModelFormSet
1113
from django.forms.formsets import DELETION_FIELD_NAME
1214
from django.urls import reverse_lazy
@@ -28,6 +30,8 @@
2830
from component_catalog.license_expression_dje import LicenseExpressionFormMixin
2931
from component_catalog.models import Component
3032
from component_catalog.programming_languages import PROGRAMMING_LANGUAGES
33+
from dejacode_toolkit.scancodeio import ScanCodeIO
34+
from dje import tasks
3135
from dje.fields import SmartFileField
3236
from dje.forms import ColorCodeFormMixin
3337
from dje.forms import DataspacedAdminForm
@@ -47,6 +51,7 @@
4751
from product_portfolio.models import Product
4852
from product_portfolio.models import ProductComponent
4953
from product_portfolio.models import ProductPackage
54+
from product_portfolio.models import ScanCodeProject
5055

5156

5257
class NameVersionValidationFormMixin:
@@ -560,6 +565,27 @@ def helper(self):
560565
helper.add_input(Submit("import", "Import"))
561566
return helper
562567

568+
def save(self, product):
569+
from product_portfolio.importers import ImportFromScan
570+
571+
sid = transaction.savepoint()
572+
importer = ImportFromScan(
573+
product,
574+
self.user,
575+
upload_file=self.cleaned_data.get("upload_file"),
576+
create_codebase_resources=self.cleaned_data.get("create_codebase_resources"),
577+
stop_on_error=self.cleaned_data.get("stop_on_error"),
578+
)
579+
580+
try:
581+
warnings, created_counts = importer.save()
582+
except ValidationError:
583+
transaction.savepoint_rollback(sid)
584+
raise
585+
586+
transaction.savepoint_commit(sid)
587+
return warnings, created_counts
588+
563589

564590
class LoadSBOMsForm(forms.Form):
565591
input_file = SmartFileField(
@@ -597,6 +623,24 @@ def helper(self):
597623
helper.add_input(Submit("submit", "Load Packages", css_class="btn-success"))
598624
return helper
599625

626+
def submit(self, product, user):
627+
scancode_project = ScanCodeProject.objects.create(
628+
product=product,
629+
dataspace=product.dataspace,
630+
type=ScanCodeProject.ProjectType.LOAD_SBOMS,
631+
input_file=self.cleaned_data.get("input_file"),
632+
update_existing_packages=self.cleaned_data.get("update_existing_packages"),
633+
scan_all_packages=self.cleaned_data.get("scan_all_packages"),
634+
created_by=user,
635+
)
636+
637+
transaction.on_commit(
638+
lambda: tasks.scancodeio_submit_load_sbom.delay(
639+
scancodeproject_uuid=scancode_project.uuid,
640+
user_uuid=user.uuid,
641+
)
642+
)
643+
600644

601645
class StrongTextWidget(forms.Widget):
602646
def render(self, name, value, attrs=None, renderer=None):
@@ -847,3 +891,35 @@ def helper(self):
847891
helper.form_id = "pull-project-data-form"
848892
helper.attrs = {"autocomplete": "off"}
849893
return helper
894+
895+
def get_project_data(self, project_name_or_uuid, user):
896+
scancodeio = ScanCodeIO(user)
897+
for field_name in ["name", "uuid"]:
898+
project_data = scancodeio.find_project(**{field_name: project_name_or_uuid})
899+
if project_data:
900+
return project_data
901+
902+
def submit(self, product, user):
903+
project_name_or_uuid = self.cleaned_data.get("project_name_or_uuid")
904+
project_data = self.get_project_data(project_name_or_uuid, user)
905+
906+
if not project_data:
907+
msg = f'Project "{project_name_or_uuid}" not found on ScanCode.io.'
908+
raise ValidationError(msg)
909+
910+
scancode_project = ScanCodeProject.objects.create(
911+
product=product,
912+
dataspace=product.dataspace,
913+
type=ScanCodeProject.ProjectType.PULL_FROM_SCANCODEIO,
914+
project_uuid=project_data.get("uuid"),
915+
update_existing_packages=self.cleaned_data.get("update_existing_packages"),
916+
scan_all_packages=False,
917+
status=ScanCodeProject.Status.SUBMITTED,
918+
created_by=user,
919+
)
920+
921+
transaction.on_commit(
922+
lambda: tasks.pull_project_data_from_scancodeio.delay(
923+
scancodeproject_uuid=scancode_project.uuid,
924+
)
925+
)

product_portfolio/templates/product_portfolio/import_from_scan.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ <h5>Option 2: From ScanCode.io pipeline results</h5>
5050
Upload a ScanCode.io JSON output file, <strong>generated with one of the following pipelines:</strong>
5151
</p>
5252
<p class="mb-0">
53-
<code>docker</code>, <code>docker_windows</code>, <code>inspect_manifest</code>, <code>load_inventory</code>
54-
<code>root_filesystems</code>, <code>scan_codebase</code>, <code>scan_package</code>
53+
<code>analyze_docker_image</code>,
54+
<code>analyze_windows_docker_image</code>,
55+
<code>inspect_packages</code>,
56+
<code>scan_codebase</code>,
57+
<code>scan_single_package</code>
5558
</p>
5659
</div>
5760

0 commit comments

Comments
 (0)