Skip to content

Commit 7fabcb2

Browse files
authored
Add an entry point for executing pipeline as a single command (#1256)
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent 49f9c07 commit 7fabcb2

17 files changed

+287
-68
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ __pycache__/
33
*.py[cod]
44

55
*.db
6+
*.sqlite3
67
.installed.cfg
78
parts
89
develop-eggs

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ v34.6.0 (unreleased)
2525
Add full dependency tree in the CycloneDX output.
2626
https://github.com/nexB/scancode.io/issues/1066
2727

28+
- Add a new ``run`` entry point for executing pipeline as a single command.
29+
https://github.com/nexB/scancode.io/pull/1256
30+
2831
v34.5.0 (2024-05-22)
2932
--------------------
3033

docs/command-line-interface.rst

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ Displays status information about the ``PROJECT`` project.
238238
The full logs of each pipeline execution are displayed by default.
239239
This can be disabled providing the ``--verbosity 0`` option.
240240

241+
.. _cli_output:
241242

242243
`$ scanpipe output --project PROJECT --format {json,csv,xlsx,spdx,cyclonedx,attribution}`
243244
-----------------------------------------------------------------------------------------
@@ -324,3 +325,52 @@ API key.
324325
Optional arguments:
325326

326327
- ``--no-input`` Does not prompt the user for input of any kind.
328+
329+
.. _cli_run:
330+
331+
`$ run PIPELINE_NAME INPUT_LOCATION`
332+
------------------------------------
333+
334+
A ``run`` command is available for executing pipelines and printing the results
335+
without providing any configuration. This can be useful for running a pipeline to get
336+
the results without the need to persist the data in the database or access the UI to
337+
review the results.
338+
339+
.. tip:: You can run multiple pipelines by providing their names, comma-separated,
340+
such as `pipeline1,pipeline2`.
341+
342+
Optional arguments:
343+
344+
- ``--project PROJECT_NAME``: Provide a project name; otherwise, a random value is
345+
generated.
346+
- ``--format {json,spdx,cyclonedx,attribution}``: Specify the output format.
347+
**The default format is JSON**.
348+
349+
For example, running the ``inspect_packages`` pipeline on a manifest file:
350+
351+
.. code-block:: bash
352+
353+
$ run inspect_packages path/to/package.json > results.json
354+
355+
In the following example, running the ``scan_codebase`` followed by the
356+
``find_vulnerabilities`` pipelines on a codebase directory:
357+
358+
.. code-block:: bash
359+
360+
$ run scan_codebase,find_vulnerabilities path/to/codebase/ > results.json
361+
362+
Using a URL as input is also supported:
363+
364+
.. code-block:: bash
365+
366+
$ run scan_single_package https://url.com/package.zip > results.json
367+
$ run analyze_docker_image docker://postgres:16 > results.json
368+
369+
In the last example, the ``--format`` option is used to generate a CycloneDX SBOM
370+
instead of the default JSON output.
371+
372+
.. code-block:: bash
373+
374+
$ run scan_codebase codebase/ --format cyclonedx > bom.json
375+
376+
See the :ref:`cli_output` for more information about supported output formats.

docs/faq.rst

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,25 @@ our frequently asked questions.
99
How can I run a scan?
1010
---------------------
1111

12-
You simply start by creating a :ref:`new project <user_interface_create_new_project>`
12+
Once you've completed the ScanCode.io app installation,
13+
you simply start by creating a :ref:`new project <user_interface_create_new_project>`
1314
and run the appropriate pipeline.
1415

15-
ScanCode.io offers several :ref:`built_in_pipelines` depending on your input, see above.
16+
ScanCode.io offers several :ref:`built_in_pipelines` depending on your input, see
17+
the :ref:`faq_which_pipeline` bellow.
18+
19+
As an alternative, I you simply which to run a pipeline without installing ScanCode.io
20+
you may use the Docker image to run pipelines as a single command:
21+
22+
.. code-block:: bash
23+
24+
docker run --rm \
25+
-v "$(pwd)":/codedrop \
26+
ghcr.io/nexb/scancode.io:latest \
27+
sh -c "run scan_codebase /codedrop" \
28+
> results.json
29+
30+
Refer to the :ref:`cli_run` section for more about this approach.
1631

1732
.. _faq_which_pipeline:
1833

scancodeio/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,27 @@ def command_line():
9595

9696
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "scancodeio.settings")
9797
execute_from_command_line(sys.argv)
98+
99+
100+
def combined_run():
101+
"""
102+
Command line entry point for executing pipeline as a single command.
103+
104+
This function sets up a pre-configured settings context, requiring no additional
105+
configuration.
106+
It combines the creation, execution, and result retrieval of the project into a
107+
single process.
108+
"""
109+
from django.core.checks.security.base import SECRET_KEY_INSECURE_PREFIX
110+
from django.core.management import execute_from_command_line
111+
from django.core.management.utils import get_random_secret_key
112+
113+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "scancodeio.settings")
114+
secret_key = SECRET_KEY_INSECURE_PREFIX + get_random_secret_key()
115+
os.environ.setdefault("SECRET_KEY", secret_key)
116+
os.environ.setdefault("SCANCODEIO_DB_ENGINE", "django.db.backends.sqlite3")
117+
os.environ.setdefault("SCANCODEIO_DB_NAME", "scancodeio.sqlite3")
118+
os.environ.setdefault("SCANCODEIO_PROCESSES", "0") # Disable multiprocessing
119+
120+
sys.argv.insert(1, "run")
121+
execute_from_command_line(sys.argv)

scanpipe/management/commands/__init__.py

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# Visit https://github.com/nexB/scancode.io for support and download.
2222

2323
import shutil
24+
import sys
2425
from pathlib import Path
2526

2627
from django.apps import apps
@@ -45,16 +46,18 @@
4546
class ProjectCommand(BaseCommand):
4647
"""
4748
Base class for management commands that take a mandatory --project argument.
48-
The project is retrieved from the database and stored on the intance as
49+
The project is retrieved from the database and stored on the instance as
4950
`self.project`.
5051
"""
5152

5253
project = None
54+
verbosity = 1
5355

5456
def add_arguments(self, parser):
5557
parser.add_argument("--project", required=True, help="Project name.")
5658

5759
def handle(self, *args, **options):
60+
self.verbosity = options["verbosity"]
5861
project_name = options["project"]
5962
try:
6063
self.project = Project.objects.get(name=project_name)
@@ -237,6 +240,15 @@ def extract_group_from_pipelines(pipelines):
237240
return pipelines_data
238241

239242

243+
def validate_pipeline(pipeline_name):
244+
"""Raise an error if the ``pipeline_name`` is not available."""
245+
if pipeline_name not in scanpipe_app.pipelines:
246+
raise CommandError(
247+
f"{pipeline_name} is not a valid pipeline. \n"
248+
f"Available: {', '.join(scanpipe_app.pipelines.keys())}"
249+
)
250+
251+
240252
def validate_pipelines(pipelines_data):
241253
"""Raise an error if one of the `pipeline_names` is not available."""
242254
# Backward compatibility with old pipeline names.
@@ -246,11 +258,7 @@ def validate_pipelines(pipelines_data):
246258
}
247259

248260
for pipeline_name in pipelines_data.keys():
249-
if pipeline_name not in scanpipe_app.pipelines:
250-
raise CommandError(
251-
f"{pipeline_name} is not a valid pipeline. \n"
252-
f"Available: {', '.join(scanpipe_app.pipelines.keys())}"
253-
)
261+
validate_pipeline(pipeline_name)
254262

255263
return pipelines_data
256264

@@ -316,7 +324,7 @@ def handle_input_files(project, input_files_data, command=None):
316324
tag=tag,
317325
)
318326

319-
if command:
327+
if command and command.verbosity > 0:
320328
msg = f"File{pluralize(copied)} copied to the project inputs directory:"
321329
command.stdout.write(msg, command.style.SUCCESS)
322330
msg = "\n".join(["- " + filename for filename in copied])
@@ -333,7 +341,7 @@ def handle_input_urls(project, input_urls, command=None):
333341
if downloads:
334342
project.add_downloads(downloads)
335343
msg = "File(s) downloaded to the project inputs directory:"
336-
if command:
344+
if command and command.verbosity > 0:
337345
command.stdout.write(msg, command.style.SUCCESS)
338346
msg = "\n".join(["- " + downloaded.filename for downloaded in downloads])
339347
command.stdout.write(msg)
@@ -347,7 +355,7 @@ def handle_input_urls(project, input_urls, command=None):
347355
def handle_copy_codebase(project, copy_from, command=None):
348356
"""Copy `codebase_path` tree to the project's `codebase` directory."""
349357
project_codebase = project.codebase_path
350-
if command:
358+
if command and command.verbosity > 0:
351359
msg = f"{copy_from} content copied in {project_codebase}"
352360
command.stdout.write(msg, command.style.SUCCESS)
353361
shutil.copytree(src=copy_from, dst=project_codebase, dirs_exist_ok=True)
@@ -371,7 +379,8 @@ def add_project_inputs(
371379
handle_copy_codebase(project=project, copy_from=copy_from, command=command)
372380

373381

374-
def execute_project(project, run_async=False, command=None):
382+
def execute_project(project, run_async=False, command=None): # noqa: C901
383+
verbosity = getattr(command, "verbosity", 1) if command else 0
375384
run = project.get_next_run()
376385

377386
if not run:
@@ -383,29 +392,31 @@ def execute_project(project, run_async=False, command=None):
383392
raise CommandError(msg)
384393

385394
run.start()
386-
if command:
395+
if verbosity > 0:
387396
msg = f"{run.pipeline_name} added to the tasks queue for execution."
388397
command.stdout.write(msg, command.style.SUCCESS)
389-
else:
398+
sys.exit(0)
399+
400+
if verbosity > 0:
390401
command.stdout.write(f"Start the {run.pipeline_name} pipeline execution...")
391402

392-
try:
393-
tasks.execute_pipeline_task(run.pk)
394-
except KeyboardInterrupt:
395-
run.set_task_stopped()
396-
raise CommandError("Pipeline execution stopped.")
397-
except Exception as e:
398-
run.set_task_ended(exitcode=1, output=str(e))
399-
raise CommandError(e)
400-
401-
run.refresh_from_db()
402-
403-
if run.task_succeeded and command:
404-
msg = f"{run.pipeline_name} successfully executed on " f"project {project}"
405-
command.stdout.write(msg, command.style.SUCCESS)
406-
else:
407-
msg = f"Error during {run.pipeline_name} execution:\n{run.task_output}"
408-
raise CommandError(msg)
403+
try:
404+
tasks.execute_pipeline_task(run.pk)
405+
except KeyboardInterrupt:
406+
run.set_task_stopped()
407+
raise CommandError("Pipeline execution stopped.")
408+
except Exception as e:
409+
run.set_task_ended(exitcode=1, output=str(e))
410+
raise CommandError(e)
411+
412+
run.refresh_from_db()
413+
414+
if not run.task_succeeded:
415+
msg = f"Error during {run.pipeline_name} execution:\n{run.task_output}"
416+
raise CommandError(msg)
417+
elif verbosity > 0:
418+
msg = f"{run.pipeline_name} successfully executed on " f"project {project}"
419+
command.stdout.write(msg, command.style.SUCCESS)
409420

410421

411422
def create_project(
@@ -419,6 +430,8 @@ def create_project(
419430
run_async=False,
420431
command=None,
421432
):
433+
verbosity = getattr(command, "verbosity", 1)
434+
422435
if execute and not pipelines:
423436
raise CommandError("The execute argument requires one or more pipelines.")
424437

@@ -440,7 +453,7 @@ def create_project(
440453
if command:
441454
command.project = project
442455

443-
if command:
456+
if command and verbosity > 0:
444457
msg = f"Project {name} created with work directory {project.work_directory}"
445458
command.stdout.write(msg, command.style.SUCCESS)
446459

scanpipe/management/commands/add-pipeline.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ def handle(self, *pipelines, **options):
4949
self.project.add_pipeline(pipeline_name, selected_groups=selected_groups)
5050

5151
pipeline_names = pipelines_data.keys()
52-
msg = (
53-
f"Pipeline{pluralize(pipeline_names)} {', '.join(pipeline_names)} "
54-
f"added to the project"
55-
)
56-
self.stdout.write(msg, self.style.SUCCESS)
52+
53+
if self.verbosity > 0:
54+
msg = (
55+
f"Pipeline{pluralize(pipeline_names)} {', '.join(pipeline_names)} "
56+
f"added to the project"
57+
)
58+
self.stdout.write(msg, self.style.SUCCESS)

scanpipe/management/commands/archive-project.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ def handle(self, *inputs, **options):
6868
f"Type 'yes' to continue, or 'no' to cancel: "
6969
)
7070
if confirm != "yes":
71-
self.stdout.write("Archive cancelled.")
71+
if self.verbosity > 0:
72+
self.stdout.write("Archive cancelled.")
7273
sys.exit(0)
7374

7475
try:
@@ -80,5 +81,6 @@ def handle(self, *inputs, **options):
8081
except RunInProgressError as error:
8182
raise CommandError(error)
8283

83-
msg = f"The {self.project} project has been archived."
84-
self.stdout.write(msg, self.style.SUCCESS)
84+
if self.verbosity > 0:
85+
msg = f"The {self.project} project has been archived."
86+
self.stdout.write(msg, self.style.SUCCESS)

scanpipe/management/commands/create-project.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
class Command(CreateProjectCommandMixin, AddInputCommandMixin, BaseCommand):
3131
help = "Create a ScanPipe project."
32+
verbosity = 1
3233

3334
def add_arguments(self, parser):
3435
super().add_arguments(parser)
@@ -55,6 +56,7 @@ def add_arguments(self, parser):
5556
)
5657

5758
def handle(self, *args, **options):
59+
self.verbosity = options["verbosity"]
5860
name = options["name"]
5961
pipelines = options["pipelines"]
6062
input_files = options["input_files"]

scanpipe/management/commands/create-user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def handle(self, *args, **options):
6565
user = self.UserModel._default_manager.create_user(username, password=password)
6666
token, _ = Token._default_manager.get_or_create(user=user)
6767

68-
if options["verbosity"] >= 1:
68+
if options["verbosity"] > 0:
6969
msg = f"User {username} created with API key: {token.key}"
7070
self.stdout.write(msg, self.style.SUCCESS)
7171

0 commit comments

Comments
 (0)