You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/17 16:21:26 UTC

(tooling-trusted-releases) branch sbp updated: Add an API endpoint to generate augmented SBOMs

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 3faf688e Add an API endpoint to generate augmented SBOMs
3faf688e is described below

commit 3faf688e33140d4d0110b971116dc3064be40e33
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Jul 17 17:20:54 2026 +0100

    Add an API endpoint to generate augmented SBOMs
---
 atr/api/__init__.py                               |  63 +++++++++++
 atr/models/api.py                                 |  20 ++++
 atr/models/attestable.py                          |   1 +
 atr/models/results.py                             |  15 +++
 atr/models/sql.py                                 |   6 ++
 atr/storage/writers/sbom.py                       |  50 +++++++++
 atr/tasks/__init__.py                             |   2 +
 atr/tasks/sbom.py                                 |  64 +++++++++++
 tests/unit/test_api_sbom_generate.py              | 126 ++++++++++++++++++++++
 tests/unit/test_archived_project_writer_guards.py |  20 ++++
 10 files changed, 367 insertions(+)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 5f51ce24..6dc07520 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1557,6 +1557,44 @@ async def releases_list(
     ).model_dump(mode="json"), 200
 
 
+@api.typed(
+    auth_scheme=api_auth.Auth.BEARER,
+    response=(models.api.SbomGenerateResults, 202),
+    rate_limit=(10, datetime.timedelta(hours=1)),
+)
+async def sbom_generate(
+    _sbom_generate: Literal["sbom/generate"],
+    data: models.api.SbomGenerateArgs,
+) -> DictResponse:
+    asf_uid = _jwt_asf_uid()
+    if not str(data.relpath).endswith((".tar.gz", ".tgz", ".zip", ".jar")):
+        raise exceptions.BadRequest("SBOM generation requires .tar.gz, .tgz, .zip or .jar files")
+    async with db.session() as db_data:
+        release_key = sql.release_key(data.project, data.version)
+        release = await db_data.release(key=str(release_key), _committee=True).get()
+    if (release is None) or user.embargo_hides_release(release, asf_uid, is_member=False):
+        raise exceptions.NotFound(f"Release {release_key} not found")
+    if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+        raise exceptions.Conflict(f"Release {release_key} is not a candidate draft")
+
+    try:
+        async with storage.write(asf_uid) as write:
+            wacp = await write.as_project_committee_participant(data.project)
+            task = await wacp.sbom.generate_cyclonedx_revision(
+                data.project,
+                data.version,
+                release.safe_latest_revision_number,
+                data.relpath,
+            )
+    except storage.AccessError as e:
+        raise _http_exception_from_storage_access_error(e) from e
+
+    return models.api.SbomGenerateResults(
+        endpoint="/sbom/generate",
+        task=task,
+    ).model_dump(mode="json"), 202
+
+
 @api.typed(
     auth_scheme=api_auth.Auth.BEARER,
     response=(models.api.SignatureProvenanceResults, 200),
@@ -1707,6 +1745,31 @@ async def ssh_keys_list(
     ).model_dump(mode="json"), 200
 
 
+@api.typed(
+    auth_scheme=api_auth.Auth.BEARER,
+    response=(models.api.TaskGetResults, 200),
+)
+async def task_get(
+    _task_get: Literal["task/get"],
+    task_id: int,
+) -> DictResponse:
+    asf_uid = _jwt_asf_uid()
+    async with db.session() as data:
+        task = await data.task(id=task_id).get()
+        release = None
+        if (task is not None) and (task.project_key is not None) and (task.version_key is not None):
+            release_key = sql.release_key(task.project_key, task.version_key)
+            release = await data.release(key=str(release_key), _committee=True).get()
+    if (task is None) or (task.asf_uid != asf_uid):
+        raise exceptions.NotFound(f"Task {task_id} not found")
+    if (release is not None) and user.embargo_hides_release(release, asf_uid, is_member=False):
+        raise exceptions.NotFound(f"Task {task_id} not found")
+    return models.api.TaskGetResults(
+        endpoint="/task/get",
+        task=task,
+    ).model_dump(mode="json"), 200
+
+
 @api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
 async def update_distribution_task_status(
     _distribute_task_status: Literal["distribute/task/status"],
diff --git a/atr/models/api.py b/atr/models/api.py
index 78a4122f..a2630129 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -786,6 +786,17 @@ class ReleasesListResults(schema.Strict):
     count: int
 
 
+class SbomGenerateArgs(schema.Strict):
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
+    relpath: safe.RelPath = schema.example("example-0.0.1-bin.tar.gz")
+
+
+class SbomGenerateResults(schema.Strict):
+    endpoint: Literal["/sbom/generate"] = schema.alias("endpoint")
+    task: sql.Task
+
+
 class SignatureProvenanceArgs(schema.Strict):
     signature_file_name: str = schema.example("example-0.0.1-bin.tar.gz.asc")
     signature_asc_text: str = schema.example("-----BEGIN PGP SIGNATURE-----\n\n...\n-----END PGP SIGNATURE-----\n")
@@ -837,6 +848,11 @@ class SshKeysListResults(schema.Strict):
     count: int = schema.example(10)
 
 
+class TaskGetResults(schema.Strict):
+    endpoint: Literal["/task/get"] = schema.alias("endpoint")
+    task: sql.Task
+
+
 @dataclasses.dataclass
 class TasksListQuery:
     limit: int = 20
@@ -993,10 +1009,12 @@ type Results = Annotated[
     | ReleaseRevisionsResults
     | ReleaseUploadResults
     | ReleasesListResults
+    | SbomGenerateResults
     | SignatureProvenanceResults
     | SshKeyAddResults
     | SshKeyDeleteResults
     | SshKeysListResults
+    | TaskGetResults
     | TasksListResults
     | UserInfoResults
     | UsersListResults
@@ -1054,10 +1072,12 @@ validate_release_paths = validator(ReleasePathsResults)
 validate_release_revisions = validator(ReleaseRevisionsResults)
 validate_release_upload = validator(ReleaseUploadResults)
 validate_releases_list = validator(ReleasesListResults)
+validate_sbom_generate = validator(SbomGenerateResults)
 validate_signature_provenance = validator(SignatureProvenanceResults)
 validate_ssh_key_add = validator(SshKeyAddResults)
 validate_ssh_key_delete = validator(SshKeyDeleteResults)
 validate_ssh_keys_list = validator(SshKeysListResults)
+validate_task_get = validator(TaskGetResults)
 validate_tasks_list = validator(TasksListResults)
 validate_user_info = validator(UserInfoResults)
 validate_users_list = validator(UsersListResults)
diff --git a/atr/models/attestable.py b/atr/models/attestable.py
index 8ea29432..2ec7a44c 100644
--- a/atr/models/attestable.py
+++ b/atr/models/attestable.py
@@ -54,6 +54,7 @@ class HashEntryV2(schema.Strict):
 
 
 class GeneratorV2(enum.Enum):
+    SBOM_FROM_ARTIFACT = "SBOM_from_artifact"
     SHA512_FROM_CONTENT = "SHA512_from_content"
     SHA512_FROM_SIGNATURE = "SHA512_from_signature"
 
diff --git a/atr/models/results.py b/atr/models/results.py
index 831ee178..9082bc1b 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -177,6 +177,20 @@ class SBOMConvert(schema.Strict):
     )
 
 
+class SBOMGenerate(schema.Strict):
+    kind: Literal["sbom_generate"] = schema.Field(alias="kind")
+    path: str = schema.description("The path to the generated SBOM file, relative to the release directory")
+    bom_version: int | None = schema.Field(
+        default=None,
+        strict=False,
+        description="BOM Version produced by the augmentation, if any augmentations were applied",
+    )
+    revision_number: str | None = schema.Field(
+        default=None,
+        description="The number of the new revision containing the generated SBOM file",
+    )
+
+
 class SBOMQsScore(schema.Strict):
     kind: Literal["sbom_qs_score"] = schema.Field(alias="kind")
     project_key: safe.ProjectKey = schema.description("Project name")
@@ -293,6 +307,7 @@ Results = Annotated[
     | MetadataUpdate
     | SBOMAugment
     | SBOMConvert
+    | SBOMGenerate
     | SBOMGenerateCycloneDX
     | SBOMOSVScan
     | SBOMQsScore
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 756e8c56..12c3b51a 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -248,6 +248,7 @@ class TaskType(enum.StrEnum):
     RAT_CHECK = "rat_check"
     SBOM_AUGMENT = "sbom_augment"
     SBOM_CONVERT = "sbom_convert"
+    SBOM_GENERATE = "sbom_generate"
     SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
     SBOM_OSV_SCAN = "sbom_osv_scan"
     SBOM_QS_SCORE = "sbom_qs_score"
@@ -303,6 +304,8 @@ class TaskType(enum.StrEnum):
                 return "SBOM augmentation"
             case TaskType.SBOM_CONVERT:
                 return "SBOM conversion"
+            case TaskType.SBOM_GENERATE:
+                return "SBOM generation and augmentation"
             case TaskType.SBOM_GENERATE_CYCLONEDX:
                 return "SBOM generation"
             case TaskType.SBOM_OSV_SCAN:
@@ -763,6 +766,9 @@ class Task(sqlmodel.SQLModel, table=True):
         if isinstance(self.completed, str):
             self.completed = datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
 
+        if isinstance(self.result, dict):
+            self.result = results.ResultsAdapter.validate_python(self.result)
+
     @property
     def safe_primary_rel_path(self) -> safe.RelPath | None:
         """Get the typesafe validated relative path for the task, if set."""
diff --git a/atr/storage/writers/sbom.py b/atr/storage/writers/sbom.py
index 8e984348..c92f68bd 100644
--- a/atr/storage/writers/sbom.py
+++ b/atr/storage/writers/sbom.py
@@ -176,6 +176,56 @@ class CommitteeParticipant(FoundationCommitter):
         await self.__data.refresh(sbom_task)
         return sbom_task
 
+    async def generate_cyclonedx_revision(
+        self,
+        project_key: safe.ProjectKey,
+        version_key: safe.VersionKey,
+        revision_number: safe.RevisionNumber,
+        rel_path: safe.RelPath,
+    ) -> sql.Task:
+        await self.__data.begin_immediate()
+        release_key = sql.release_key(str(project_key), str(version_key))
+        release = await self.__data.release(key=str(release_key)).demand(
+            storage.AccessError(f"Release '{project_key!s} {version_key!s}' not found.", status=404)
+        )
+        storage.ensure_project_active(release.project)
+        self.__write.ensure_release_writable(release)
+        if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+            raise storage.AccessError(
+                f"Release '{project_key!s} {version_key!s}' is not a candidate draft.", status=409
+            )
+        ongoing = await self.__data.task(
+            status_in=[sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE],
+            task_type=sql.TaskType.SBOM_GENERATE,
+            asf_uid=util.unwrap(self.__asf_uid),
+            project_key=str(project_key),
+            version_key=str(version_key),
+            primary_rel_path=str(rel_path),
+        ).all()
+        if ongoing:
+            return ongoing[0]
+        sbom_task = sql.Task(
+            task_type=sql.TaskType.SBOM_GENERATE,
+            task_args=args.FileArgs(
+                project_key=project_key,
+                version_key=version_key,
+                revision_number=revision_number,
+                file_path=rel_path,
+                asf_uid=util.unwrap(self.__asf_uid),
+            ).model_dump(),
+            asf_uid=util.unwrap(self.__asf_uid),
+            added=datetime.datetime.now(datetime.UTC),
+            status=sql.TaskStatus.QUEUED,
+            project_key=str(project_key),
+            version_key=str(version_key),
+            revision_number=str(revision_number),
+            primary_rel_path=str(rel_path),
+        )
+        self.__data.add(sbom_task)
+        await self.__data.commit()
+        await self.__data.refresh(sbom_task)
+        return sbom_task
+
     async def osv_scan_cyclonedx(
         self,
         project_key: safe.ProjectKey,
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 5f35a1d8..56b1a1a1 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -381,6 +381,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
             return sbom.augment
         case sql.TaskType.SBOM_CONVERT:
             return sbom.convert_cyclonedx
+        case sql.TaskType.SBOM_GENERATE:
+            return sbom.generate
         case sql.TaskType.SBOM_GENERATE_CYCLONEDX:
             return sbom.generate_cyclonedx
         case sql.TaskType.SBOM_OSV_SCAN:
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 3fb8e712..43133c0c 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -28,8 +28,10 @@ import orjson
 import atr.analysis as analysis
 import atr.archives as archives
 import atr.config as config
+import atr.hashes as hashes
 import atr.log as log
 import atr.models.args as args
+import atr.models.attestable as attestable
 import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
@@ -141,6 +143,68 @@ async def convert_cyclonedx(args: args.ConvertCycloneDX) -> results.Results | No
         raise
 
 
+@checks.with_model(args.FileArgs)
+async def generate(args: args.FileArgs) -> results.Results | None:
+    revision_str = str(args.revision_number)
+    path_str = str(args.file_path)
+
+    base_dir = paths.get_unfinished_dir_for(args.project_key, args.version_key, args.revision_number)
+    if not await aiofiles.os.path.isdir(base_dir):
+        raise SBOMGenerationError("Revision directory does not exist", {"base_dir": str(base_dir)})
+    if not await aiofiles.os.path.isfile(base_dir / path_str):
+        raise SBOMGenerationError("Artifact file does not exist", {"file_path": path_str})
+    sbom_rel_path = path_str + ".cdx.json"
+    bom_version: int | None = None
+    description = "SBOM generation through the API"
+    async with storage.write(args.asf_uid) as write:
+        wacp = await write.as_project_committee_participant(args.project_key)
+
+        async def modify(
+            path: safe.StatePath, _old_rev: sql.Revision | None
+        ) -> dict[safe.RelPath, attestable.ProvenanceV2] | None:
+            nonlocal bom_version
+            artifact_path = path / path_str
+            output_path = path / sbom_rel_path
+            if await aiofiles.os.path.exists(output_path):
+                raise SBOMGenerationError("SBOM file already exists", {"file_path": sbom_rel_path})
+            source_content_hash = await hashes.compute_file_hash(artifact_path)
+            await _generate_cyclonedx_core(artifact_path, output_path)
+            bundle = sbom.utilities.path_to_bundle(output_path.path)
+            if not bundle:
+                raise SBOMGenerationError("Could not load bundle")
+            patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
+            if patch_ops:
+                bom_version, merged = sbom.utilities.apply_patch("augment", revision_str, bundle, patch_ops)
+                await aiofiles.os.remove(output_path)
+                async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
+                    await f.write(orjson.dumps(merged).decode())
+            provenance = attestable.ProvenanceV2(
+                generator=attestable.GeneratorV2.SBOM_FROM_ARTIFACT,
+                metadata={
+                    "initiated_by": args.asf_uid,
+                    "source_content_hashes": {path_str: source_content_hash},
+                    "source_paths": [path_str],
+                },
+            )
+            return {safe.RelPath(sbom_rel_path): provenance}
+
+        result = await wacp.revision.create_revision_with_quarantine(
+            args.project_key,
+            args.version_key,
+            args.asf_uid or "unknown",
+            allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
+            description=description,
+            modify=modify,
+        )
+
+    return results.SBOMGenerate(
+        kind="sbom_generate",
+        path=sbom_rel_path,
+        bom_version=bom_version,
+        revision_number=(result.number if isinstance(result, sql.Revision) else None),
+    )
+
+
 @checks.with_model(args.GenerateCycloneDX)
 async def generate_cyclonedx(args: args.GenerateCycloneDX) -> results.Results | None:
     """Generate a CycloneDX SBOM for the given artifact and write it to the output path."""
diff --git a/tests/unit/test_api_sbom_generate.py b/tests/unit/test_api_sbom_generate.py
new file mode 100644
index 00000000..f65c2e1b
--- /dev/null
+++ b/tests/unit/test_api_sbom_generate.py
@@ -0,0 +1,126 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import types
+import unittest.mock as mock
+
+import pytest
+
+import atr.models as models
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.writers.sbom
+import atr.tasks as tasks
+import atr.tasks.sbom as sbom
+
+
+def test_sbom_generate_results_round_trip() -> None:
+    task = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="test")
+    api_results = models.api.SbomGenerateResults(endpoint="/sbom/generate", task=task)
+
+    parsed = models.api.validate_sbom_generate(api_results.model_dump(mode="json"))
+
+    assert parsed.task.task_type == sql.TaskType.SBOM_GENERATE
+
+
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_blocks_non_draft() -> None:
+    data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    release_query = mock.MagicMock()
+    release_query.demand = mock.AsyncMock(return_value=_draft_release(sql.ReleasePhase.RELEASE_CANDIDATE))
+    data.release = mock.MagicMock(return_value=release_query)
+    writer = _writer_with(data)
+
+    with pytest.raises(storage.AccessError, match="candidate draft"):
+        await writer.generate_cyclonedx_revision(
+            safe.ProjectKey("project"),
+            safe.VersionKey("1.0.0"),
+            safe.RevisionNumber("00001"),
+            safe.RelPath("artifact.tar.gz"),
+        )
+
+
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_reuses_ongoing_task() -> None:
+    existing = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="tester")
+    data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    release_query = mock.MagicMock()
+    release_query.demand = mock.AsyncMock(return_value=_draft_release())
+    data.release = mock.MagicMock(return_value=release_query)
+    task_query = mock.MagicMock()
+    task_query.all = mock.AsyncMock(return_value=[existing])
+    data.task = mock.MagicMock(return_value=task_query)
+    writer = _writer_with(data)
+
+    result = await writer.generate_cyclonedx_revision(
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        safe.RevisionNumber("00001"),
+        safe.RelPath("artifact.tar.gz"),
+    )
+
+    assert result is existing
+    data.add.assert_not_called()
+    data.task.assert_called_once_with(
+        status_in=[sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE],
+        task_type=sql.TaskType.SBOM_GENERATE,
+        asf_uid="tester",
+        project_key="project",
+        version_key="1.0.0",
+        primary_rel_path="artifact.tar.gz",
+    )
+
+
+def test_sbom_generate_task_resolves() -> None:
+    assert tasks.resolve(sql.TaskType.SBOM_GENERATE) is sbom.generate
+
+
+def test_task_get_results_round_trip_typed_result() -> None:
+    task = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="test")
+    task.result = results.SBOMGenerate(
+        kind="sbom_generate",
+        path="artifact.tar.gz.cdx.json",
+        bom_version=2,
+        revision_number="00002",
+    )
+    api_results = models.api.TaskGetResults(endpoint="/task/get", task=task)
+
+    parsed = models.api.validate_task_get(api_results.model_dump(mode="json"))
+
+    assert isinstance(parsed.task.result, results.SBOMGenerate)
+    assert parsed.task.result.revision_number == "00002"
+
+
+def _draft_release(phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT) -> types.SimpleNamespace:
+    return types.SimpleNamespace(
+        project=types.SimpleNamespace(is_active=True),
+        is_embargoed=False,
+        phase=phase,
+    )
+
+
+def _writer_with(data: mock.MagicMock) -> atr.storage.writers.sbom.CommitteeParticipant:
+    writer = object.__new__(atr.storage.writers.sbom.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+    writer._CommitteeParticipant__write = mock.MagicMock()
+    return writer
diff --git a/tests/unit/test_archived_project_writer_guards.py b/tests/unit/test_archived_project_writer_guards.py
index 0d6c179c..2b695542 100644
--- a/tests/unit/test_archived_project_writer_guards.py
+++ b/tests/unit/test_archived_project_writer_guards.py
@@ -521,6 +521,26 @@ async def test_sbom_augment_blocks_retired() -> None:
         )
 
 
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_blocks_retired() -> None:
+    data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    data.release = mock.MagicMock(return_value=_query_returning(_retired_release()))
+    writer = object.__new__(sbom.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+    writer._CommitteeParticipant__write = mock.MagicMock()
+
+    with pytest.raises(storage.AccessError, match="archived"):
+        await writer.generate_cyclonedx_revision(
+            safe.ProjectKey("project"),
+            safe.VersionKey("1.0.0"),
+            safe.RevisionNumber("00001"),
+            safe.RelPath("artifact.tar.gz"),
+        )
+
+
 @pytest.mark.asyncio
 async def test_vote_resolve_release_blocks_retired_after_merge() -> None:
     data = mock.MagicMock()


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org