Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding a new merge-sboms sub-command #593

Merged
merged 1 commit into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 91 additions & 11 deletions cachi2/interface/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
import logging
import shutil
import sys
from itertools import chain
from pathlib import Path
from typing import Any, Callable, Optional

import pydantic
import typer

import cachi2.core.config as config
from cachi2.core.errors import Cachi2Error, InvalidInput
from cachi2.core.errors import Cachi2Error, InvalidInput, UnexpectedFormat
from cachi2.core.extras.envfile import EnvFormat, generate_envfile
from cachi2.core.models.input import Flag, PackageInput, Request, parse_user_input
from cachi2.core.models.output import BuildConfig
from cachi2.core.models.property_semantics import merge_component_properties
from cachi2.core.models.sbom import Sbom
from cachi2.core.resolver import inject_files_post, resolve_packages, supported_package_managers
from cachi2.core.rooted_path import RootedPath
from cachi2.interface.logging import LogLevel, setup_logging
Expand All @@ -26,6 +29,25 @@
DEFAULT_OUTPUT = "./cachi2-output"


OUTFILE_OPTION = typer.Option(
None,
"-o",
"--output",
dir_okay=False,
help="Write to this file instead of standard output.",
)


Paths = list[Path]


def _bail_out_with_error(e: Cachi2Error) -> None:
"""Report and error and set correct exit code."""
log.error("%s: %s", type(e).__name__, str(e).replace("\n", r"\n"))
print(f"Error: {type(e).__name__}: {e.friendly_msg()}", file=sys.stderr)
raise typer.Exit(2 if e.is_invalid_usage else 1)


def handle_errors(cmd: Callable[..., None]) -> Callable[..., None]:
"""Decorate a CLI command function with an error handler.

Expand All @@ -42,9 +64,7 @@ def cmd_with_error_handling(*args: tuple[Any, ...], **kwargs: dict[str, Any]) ->
try:
cmd(*args, **kwargs)
except Cachi2Error as e:
log_error(e)
print(f"Error: {type(e).__name__}: {e.friendly_msg()}", file=sys.stderr)
raise typer.Exit(2 if e.is_invalid_usage else 1)
_bail_out_with_error(e)
except Exception as e:
log_error(e)
raise
Expand Down Expand Up @@ -291,13 +311,7 @@ def combine_option_and_json_flags(json_flags: list[Flag]) -> list[str]:
def generate_env(
from_output_dir: Path = FROM_OUTPUT_DIR_ARG,
for_output_dir: Optional[Path] = FOR_OUTPUT_DIR_OPTION,
output: Optional[Path] = typer.Option(
None,
"-o",
"--output",
dir_okay=False,
help="Write to this file instead of standard output.",
),
output: Optional[Path] = OUTFILE_OPTION,
fmt: Optional[EnvFormat] = typer.Option(
None,
"-f",
Expand Down Expand Up @@ -346,6 +360,72 @@ def inject_files(
)


def _prevalidate_sbom_files_args(sbom_files_to_merge: Paths) -> Paths:
def enough_files_for_merge(sbom_files_to_merge: Paths) -> Paths:
if len(sbom_files_to_merge) < 2:
# NOTE: an exception here happens during argument evaluation phase
# i.e. outside of handle_errors() decorator. Simply raising
# an exception here will not produce correct exit code, thus
# the explicit call to exception wrapper.
_bail_out_with_error(InvalidInput("Need at least two different SBOM files"))
Copy link
Contributor

@brunoapimentel brunoapimentel Aug 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raising InvalidInput would have the same effect as wrapping it with _bail_out_with_error here, right? (considering the command is annotated with @handle_errors). This would avoid the need to extracting out _bail_out_with_error.

In case you prefer to keep it extracted, I'd say doing it in a separate commit would be better (but raising the exception here in any case seems more consistent to me).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it won't: with a raise exit code is 1, with the wrapper it is set to 2 for Cachi2Errors. This exception will happen before arguments are passed to the decorated function, thus outside of handle_error scope.

Copy link
Contributor

@brunoapimentel brunoapimentel Aug 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I got confused because, when I tested it, it errored on {sbom_file} does not appear to be a valid Cachi2 SBOM, which is already within the decorator's scope.

So a small nitpick is to extract _bail_out_with_error in a commit prior to the main one, but it already LGTM the way it is.

return sbom_files_to_merge

def all_files_are_jsons(sbom_files_to_merge: Paths) -> Paths:
for sbom_file in sbom_files_to_merge:
try:
json.loads(sbom_file.read_text())
except ValueError:
# See comment in enough_files_for_merge()
_bail_out_with_error(
UnexpectedFormat(f"{sbom_file} does not look like a SBOM file")
)
return sbom_files_to_merge

return all_files_are_jsons(enough_files_for_merge(list(set(sbom_files_to_merge))))


@app.command()
brunoapimentel marked this conversation as resolved.
Show resolved Hide resolved
@handle_errors
def merge_sboms(
sbom_files_to_merge: Paths = typer.Argument(
...,
callback=_prevalidate_sbom_files_args,
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
readable=True,
help="Names of files with SBOMs to merge.",
),
output_sbom_file_name: Optional[Path] = OUTFILE_OPTION,
) -> None:
"""Merge two or more SBOMs into one.

The command works with Cachi2-generated SBOMs only. You might want to run

cachi2 fetch-deps <args...>

first to produce SBOMs to merge.
"""
sboms_to_merge = []
for sbom_file in sbom_files_to_merge:
try:
sboms_to_merge.append(Sbom.model_validate_json(sbom_file.read_text()))
except pydantic.ValidationError:
raise UnexpectedFormat(f"{sbom_file} does not appear to be a valid Cachi2 SBOM.")
sbom = Sbom(
components=merge_component_properties(
chain.from_iterable(s.components for s in sboms_to_merge)
)
)
sbom_json = sbom.model_dump_json(indent=2, by_alias=True, exclude_none=True)

if output_sbom_file_name is not None:
output_sbom_file_name.write_text(sbom_json)
else:
print(sbom_json)


def _get_build_config(output_dir: Path) -> BuildConfig:
build_config_json = RootedPath(output_dir).join_within_root(".build-config.json").path
if not build_config_json.exists():
Expand Down
21 changes: 21 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The second section goes through each of these steps for the supported package ma
* [pre-fetch dependencies](#pre-fetch-dependencies)
* [generate environment variables](#generate-environment-variables)
* [inject project files](#inject-project-files)
* [merge SBOMs](#merge-sboms)
* [building the artifact](#Building-the-artifact-with-the-pre-fetched-dependencies)
* set the environment variables ([Containerfile example](#write-the-dockerfile-or-containerfile))
* run the build ([container build example](#build-the-container))
Expand Down Expand Up @@ -106,6 +107,26 @@ it was previously clean. If any scripting depends on the cleanliness of a git re
the changes, the scripting should either be changed to handle the dirty status or the changes should be temporarily
stashed by wrapping in `git stash && <command> && git stash pop` according to the suitability of the context.*

### Merge SBOMs

Sometimes it might be necessary to merge two or more SBOMs. This could be done with `cachi2 merge-sboms`:

```shell
cachi2 merge-sboms <cachi2_sbom_1.json> ... <cachi2_sbom_n.json>
```

The subcommand expects at least two SBOMs, all produced by Cachi2, and will exit with error
otherwise. The reason for this is that Cachi2 supports a
[limited set](https://github.com/containerbuildsystem/cachi2/blob/main/cachi2/core/models/sbom.py#L7-L13)
of component [properties](https://cyclonedx.org/docs/1.4/json/#components_items_properties),
and it validates that no other properties exist in the SBOM. By default the result of a merge
will be printed to stdout. To save it to a file use `-o` option:

```shell
cachi2 merge-sboms <cachi2_sbom_1.json> ... <cachi2_sbom_n.json> -o <merged_sbom.json>
```


### Building the Artifact with the Pre-fetched dependencies

After the pre-fetch and the above steps to inform the package manager(s) of the cache have been completed, it all
Expand Down
Loading