Skip to content

Extend ad-hoc RPM content search with package groups, errata and module streams - #1664

Merged
YasenT merged 1 commit into
pulp:mainfrom
YasenT:rpm-content-search-groups-errata-modules
Nov 4, 2026
Merged

YasenT merged 1 commit into
pulp:mainfrom
YasenT:rpm-content-search-groups-errata-modules

Conversation

@YasenT

@YasenT YasenT commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Extends the ad-hoc rpm/content-search/ endpoints (added in #1559) with three new search types, each matching the query behavior of its content-views/{pk}/search/rpm/... counterpart. Repository versions are passed directly as repeated repository_version query params — no persisted ContentView required.

Endpoint Behavior
rpm/content-search/package-groups/ Substring match on name; packages lists of same (name, id) groups across repo versions are unioned
rpm/content-search/errata/ Search on id/title, type/severity filters, sort, pagination, CVE references included
rpm/content-search/module-streams/ Name/stream substring match, rpm_names filter, sort, capped

Changes

  • New viewsets in content_search_viewsets.py, inheriting auth/RBAC from RpmContentSearchViewSet (inaccessible domains are silently excluded).
  • Refactored the query logic out of the ContentView viewsets into shared functions (package_group_search, errata_search, module_stream_search) plus shared OpenAPI params, so both endpoint families behave identically. No behavior change to the existing ContentView endpoints.
  • Functional tests for the new endpoints, docs in docs/rpm-content-search.md, changelog fragment.

Testing

  • ruff check passes on changed files.
  • Functional tests have not been run locally yet — relying on CI.
  • Note: the module-streams test only asserts an empty result, since the default setup_domain fixture repo has no modules.

🤖 Generated with Claude Code

Summary by Sourcery

Extend ad-hoc RPM content search to cover package groups, errata, and module streams without requiring a persisted Content View.

New Features:

  • Add ad-hoc RPM content search endpoints for package groups, errata, and module streams using repository versions supplied directly in requests.

Enhancements:

  • Share search logic and OpenAPI parameters between ad-hoc and Content View-backed RPM search endpoints while preserving existing behavior.
  • Support cross-domain result aggregation with access-controlled exclusion of inaccessible repository versions.

Documentation:

  • Document the ad-hoc RPM content search endpoints, query parameters, responses, access control, and limitations.

Tests:

  • Add functional coverage for package-group, errata, and module-stream ad-hoc searches, including aggregation and validation scenarios.

Chores:

  • Add a changelog entry for the expanded ad-hoc RPM content search capabilities.

…le streams

Add rpm/content-search/package-groups/, errata/ and module-streams/ endpoints,
the no-ContentView-required counterparts to content-views/{pk}/search/rpm/...
Repository versions are passed as repeated repository_version query params.

The query logic for these three searches is extracted into shared functions
(package_group_search, errata_search, module_stream_search) used by both the
ContentView-backed and ad-hoc endpoints, so their behavior stays identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR extends ad-hoc RPM content search with package groups, errata, and module streams, while refactoring the corresponding Content View search logic into shared implementations to keep behavior consistent across both endpoint families.

Sequence diagram for ad-hoc RPM content search

sequenceDiagram
    actor User
    participant API as RpmContentSearchViewSet
    participant Resolver as resolve_repository_versions
    participant Search as SharedSearchFunction
    participant Serializer as ContentSearchSerializer

    User->>API: GET rpm/content-search/{search_type}/?repository_version=...
    API->>Resolver: resolve_repository_versions
    Resolver-->>API: versions_by_domain
    API->>Search: package_group_search / errata_search / module_stream_search
    Search-->>API: filtered and merged content
    API->>Serializer: serialize results
    Serializer-->>API: response data
    API-->>User: search results
Loading

File-Level Changes

Change Details Files
Added three ad-hoc, cross-domain RPM content-search endpoints using the existing repository-version resolution and authentication/RBAC path.
  • Registered package-group, errata, and module-stream viewsets.
  • Accepted repeated repository_version parameters and silently excluded inaccessible domains.
  • Returned endpoint-specific serialized and paginated response shapes.
pulp_service/pulp_service/app/content_search_viewsets.py
pulp_service/pulp_service/app/viewsets.py
Extracted search implementations and OpenAPI parameter definitions into shared functions so ad-hoc and Content View searches use the same query behavior.
  • Shared package-group merging and package-list union logic.
  • Shared errata filtering, sorting, CVE prefetching, and pagination.
  • Shared module-stream filtering, sorting, RPM-name matching, and result caps.
  • Rewired existing Content View endpoints to call the shared implementations.
pulp_service/pulp_service/app/content_view_viewsets.py
Documented and functionally tested the new endpoint family.
  • Added API usage, parameters, response shapes, access-control behavior, errors, and limitations documentation.
  • Added coverage for cross-domain aggregation, errata filtering, empty module results, and missing repository versions.
  • Added a changelog feature fragment.
docs/rpm-content-search.md
pulp_service/pulp_service/tests/functional/test_rpm_content_search.py
CHANGES/cross-domain-rpm-content-search-groups-errata-modules.feature

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 7 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pulp_service/pulp_service/app/content_view_viewsets.py" line_range="336" />
<code_context>
+    limit = _int_param(request, "limit", default=25, minimum=1, maximum=100)
+
+    def build_queryset(versions):
+        qs = content_for_versions(PackageGroup, versions)
+        if search:
+            qs = qs.filter(name__icontains=search)
</code_context>
<issue_to_address>
**Large searches exhaust worker memory**

When A request includes multiple repository versions in one domain whose combined content-ID sets are large, `content_for_versions` adds every `version.content_ids` value to a Python set before applying the search filters or result limit. Large repository versions therefore make these new searches consume memory proportional to all content in the versions, potentially exhausting the worker before results are returned.

Avoid materializing all content IDs in Python, or otherwise bound the versions/content processed before constructing the search queryset.

Also at `pulp_service/pulp_service/app/content_view_viewsets.py:482`, `pulp_service/pulp_service/app/content_view_viewsets.py:560`.
</issue_to_address>

### Comment 2
<location path="pulp_service/pulp_service/app/content_view_viewsets.py" line_range="570-571" />
<code_context>
+    rows = []
+    for domain, versions in versions_by_domain.items():
+        with with_domain(domain):
+            rows.extend(build_queryset(versions)[:limit])
+    rows.sort(key=lambda m: getattr(m, sort_field), reverse=descending)
+    return rows[:limit]
+
</code_context>
<issue_to_address>
**Descending searches omit highest-ranked streams**

When `sort_by` is descending and a domain has more matching streams than the requested limit, `module_stream_search` slices each domain’s ascending queryset to `limit` before sorting the collected rows descending. The highest-ranked streams are discarded before the sort, so callers receive the wrong results for a descending request.

Apply the requested sort direction to each domain’s queryset before slicing it to the per-domain limit.
</issue_to_address>

### Comment 3
<location path="pulp_service/pulp_service/app/content_search_viewsets.py" line_range="203-204" />
<code_context>
+    def list(self, request, *args, **kwargs):
+        versions_by_domain = self._versions_by_domain(request)
+        page = package_group_search(request, versions_by_domain)
+        serializer = self.get_serializer(page, many=True)
+        return Response({"results": serializer.data})
+
+
</code_context>
<issue_to_address>
**Cross-domain streams lose packages**

When a stream with RPM packages comes from a domain other than the request's domain, `module_stream_search` returns the stream after `with_domain` exits, so `ContentViewModuleStreamSerializer.get_packages` lazily queries packages in the request's domain and returns incomplete or incorrect results.

Load or serialize each stream's packages while its own domain context is active.
</issue_to_address>

### Comment 4
<location path="pulp_service/pulp_service/app/content_view_viewsets.py" line_range="496" />
<code_context>
+    page, total = scatter_gather(
+        versions_by_domain,
+        build_queryset,
+        order_by=(sort_field,),
+        limit=limit,
+        offset=offset,
</code_context>
<issue_to_address>
**Paginated errata repeat or disappear**

When a caller paginates results with tied values for the requested sort field, `errata_search` passes only `sort_field` to `scatter_gather` as `order_by`, leaving tied rows without a stable order across requests; clients receive duplicate or skipped errata.

Add a unique, stable tiebreaker, such as the row ID, to the errata ordering.
</issue_to_address>

### Comment 5
<location path="pulp_service/pulp_service/app/content_view_viewsets.py" line_range="365" />
<code_context>
+                    order.append(key)
+                else:
+                    merged[key].packages = _union_package_lists(merged[key].packages, group.packages)
+    order.sort(key=lambda key: key[0])
+    return [merged[key] for key in order[:limit]]
+
</code_context>
<issue_to_address>
**Same-name groups change order and selected results**

When more than the result limit of distinct groups share a name and the repository-version input order changes, `package_group_search` sorts merged groups only by name, so equal-name groups retain domain and input iteration order; the ordering and the subset returned after applying the limit change with the supplied repository versions.

Sort merged groups by both name and a stable tiebreaker such as ID.
</issue_to_address>

### Comment 6
<location path="pulp_service/pulp_service/tests/functional/test_rpm_content_search.py" line_range="157-158" />
<code_context>
+            auth=admin_auth,
+            timeout=60,
+        )
+        assert filtered.status_code == 200, filtered.text
+        assert all(erratum["type"] == known_type for erratum in filtered.json()["results"])
+
+    def test_missing_repository_version_param_is_400(self, setup_domain, bindings_cfg, admin_auth):
</code_context>
<issue_to_address>
**Empty results pass the type-filter test**

When the type-filter endpoint returns no results despite matching errata existing, `all()` in the type-filter test returns `True` for an empty results list, so the test passes without detecting that the filter returned no errata.

Assert that the filtered results are nonempty before checking that every erratum has the expected type.
</issue_to_address>

### Comment 7
<location path="pulp_service/pulp_service/tests/functional/test_rpm_content_search.py" line_range="116" />
<code_context>
+            timeout=60,
+        )
+        assert resp.status_code == 200, resp.text
+        assert isinstance(resp.json()["results"], list)
+
+    def test_missing_repository_version_param_is_400(self, setup_domain, bindings_cfg, admin_auth):
</code_context>
<issue_to_address>
**Package-group aggregation goes unchecked**

When the package-group search drops all results, when the package-group search returns an empty list for every request, `test_search_aggregates_across_domains` still passes: line 116 checks only that `results` is a list, so it never detects that aggregation is broken.

Assert that the response contains expected groups and verifies the cross-version aggregation behavior.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 7 findings to address first, and if repository-version resolution or domain scoping is wrong, an authenticated caller could retrieve content from a domain they cannot read. Reverting removes the endpoints, but cannot retract content already returned.

Blocking findings: pulp_service/pulp_service/app/content_view_viewsets.py:336, pulp_service/pulp_service/app/content_view_viewsets.py:571, pulp_service/pulp_service/app/content_search_viewsets.py:204, pulp_service/pulp_service/app/content_view_viewsets.py:496, pulp_service/pulp_service/app/content_view_viewsets.py:365, and 2 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

limit = _int_param(request, "limit", default=25, minimum=1, maximum=100)

def build_queryset(versions):
qs = content_for_versions(PackageGroup, versions)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Large searches exhaust worker memory

When A request includes multiple repository versions in one domain whose combined content-ID sets are large, content_for_versions adds every version.content_ids value to a Python set before applying the search filters or result limit. Large repository versions therefore make these new searches consume memory proportional to all content in the versions, potentially exhausting the worker before results are returned.

Avoid materializing all content IDs in Python, or otherwise bound the versions/content processed before constructing the search queryset.

Also at pulp_service/pulp_service/app/content_view_viewsets.py:482, pulp_service/pulp_service/app/content_view_viewsets.py:560.

Prompt for AI agents
In `pulp_service/pulp_service/app/content_view_viewsets.py` at line 336:

**Large searches exhaust worker memory**

When A request includes multiple repository versions in one domain whose combined content-ID sets are large, `content_for_versions` adds every `version.content_ids` value to a Python set before applying the search filters or result limit. Large repository versions therefore make these new searches consume memory proportional to all content in the versions, potentially exhausting the worker before results are returned.

Avoid materializing all content IDs in Python, or otherwise bound the versions/content processed before constructing the search queryset.

Also at `pulp_service/pulp_service/app/content_view_viewsets.py:482`, `pulp_service/pulp_service/app/content_view_viewsets.py:560`.

Comment on lines +570 to +571
rows.extend(build_queryset(versions)[:limit])
rows.sort(key=lambda m: getattr(m, sort_field), reverse=descending)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High · Descending searches omit highest-ranked streams

When sort_by is descending and a domain has more matching streams than the requested limit, module_stream_search slices each domain’s ascending queryset to limit before sorting the collected rows descending. The highest-ranked streams are discarded before the sort, so callers receive the wrong results for a descending request.

Apply the requested sort direction to each domain’s queryset before slicing it to the per-domain limit.

Prompt for AI agents
In `pulp_service/pulp_service/app/content_view_viewsets.py` at lines 570-571:

**Descending searches omit highest-ranked streams**

When `sort_by` is descending and a domain has more matching streams than the requested limit, `module_stream_search` slices each domain’s ascending queryset to `limit` before sorting the collected rows descending. The highest-ranked streams are discarded before the sort, so callers receive the wrong results for a descending request.

Apply the requested sort direction to each domain’s queryset before slicing it to the per-domain limit.

Comment on lines +203 to +204
serializer = self.get_serializer(page, many=True)
return Response({"results": serializer.data})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Cross-domain streams lose packages

When a stream with RPM packages comes from a domain other than the request's domain, module_stream_search returns the stream after with_domain exits, so ContentViewModuleStreamSerializer.get_packages lazily queries packages in the request's domain and returns incomplete or incorrect results.

Load or serialize each stream's packages while its own domain context is active.

Prompt for AI agents
In `pulp_service/pulp_service/app/content_search_viewsets.py` at lines 203-204:

**Cross-domain streams lose packages**

When a stream with RPM packages comes from a domain other than the request's domain, `module_stream_search` returns the stream after `with_domain` exits, so `ContentViewModuleStreamSerializer.get_packages` lazily queries packages in the request's domain and returns incomplete or incorrect results.

Load or serialize each stream's packages while its own domain context is active.

page, total = scatter_gather(
versions_by_domain,
build_queryset,
order_by=(sort_field,),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Paginated errata repeat or disappear

When a caller paginates results with tied values for the requested sort field, errata_search passes only sort_field to scatter_gather as order_by, leaving tied rows without a stable order across requests; clients receive duplicate or skipped errata.

Add a unique, stable tiebreaker, such as the row ID, to the errata ordering.

Prompt for AI agents
In `pulp_service/pulp_service/app/content_view_viewsets.py` at line 496:

**Paginated errata repeat or disappear**

When a caller paginates results with tied values for the requested sort field, `errata_search` passes only `sort_field` to `scatter_gather` as `order_by`, leaving tied rows without a stable order across requests; clients receive duplicate or skipped errata.

Add a unique, stable tiebreaker, such as the row ID, to the errata ordering.

order.append(key)
else:
merged[key].packages = _union_package_lists(merged[key].packages, group.packages)
order.sort(key=lambda key: key[0])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Same-name groups change order and selected results

When more than the result limit of distinct groups share a name and the repository-version input order changes, package_group_search sorts merged groups only by name, so equal-name groups retain domain and input iteration order; the ordering and the subset returned after applying the limit change with the supplied repository versions.

Sort merged groups by both name and a stable tiebreaker such as ID.

Prompt for AI agents
In `pulp_service/pulp_service/app/content_view_viewsets.py` at line 365:

**Same-name groups change order and selected results**

When more than the result limit of distinct groups share a name and the repository-version input order changes, `package_group_search` sorts merged groups only by name, so equal-name groups retain domain and input iteration order; the ordering and the subset returned after applying the limit change with the supplied repository versions.

Sort merged groups by both name and a stable tiebreaker such as ID.

Comment on lines +157 to +158
assert filtered.status_code == 200, filtered.text
assert all(erratum["type"] == known_type for erratum in filtered.json()["results"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Empty results pass the type-filter test

When the type-filter endpoint returns no results despite matching errata existing, all() in the type-filter test returns True for an empty results list, so the test passes without detecting that the filter returned no errata.

Assert that the filtered results are nonempty before checking that every erratum has the expected type.

Prompt for AI agents
In `pulp_service/pulp_service/tests/functional/test_rpm_content_search.py` at lines 157-158:

**Empty results pass the type-filter test**

When the type-filter endpoint returns no results despite matching errata existing, `all()` in the type-filter test returns `True` for an empty results list, so the test passes without detecting that the filter returned no errata.

Assert that the filtered results are nonempty before checking that every erratum has the expected type.

timeout=60,
)
assert resp.status_code == 200, resp.text
assert isinstance(resp.json()["results"], list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium · Package-group aggregation goes unchecked

When the package-group search drops all results, when the package-group search returns an empty list for every request, test_search_aggregates_across_domains still passes: line 116 checks only that results is a list, so it never detects that aggregation is broken.

Assert that the response contains expected groups and verifies the cross-version aggregation behavior.

Prompt for AI agents
In `pulp_service/pulp_service/tests/functional/test_rpm_content_search.py` at line 116:

**Package-group aggregation goes unchecked**

When the package-group search drops all results, when the package-group search returns an empty list for every request, `test_search_aggregates_across_domains` still passes: line 116 checks only that `results` is a list, so it never detects that aggregation is broken.

Assert that the response contains expected groups and verifies the cross-version aggregation behavior.

@YasenT

YasenT commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

/retest

@red-hat-konflux

Copy link
Copy Markdown
Contributor

All PipelineRuns for this commit have already succeeded. Use /retest <pipeline-name> to re-run a specific pipeline or /test to re-run all pipelines.

@YasenT
YasenT merged commit 6490b5a into pulp:main Nov 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant