diff --git a/CHANGES/cross-domain-rpm-content-search-groups-errata-modules.feature b/CHANGES/cross-domain-rpm-content-search-groups-errata-modules.feature new file mode 100644 index 00000000..4e7fbe8b --- /dev/null +++ b/CHANGES/cross-domain-rpm-content-search-groups-errata-modules.feature @@ -0,0 +1,5 @@ +Extended the ad-hoc ``rpm/content-search/`` endpoints (see ``cross-domain-rpm-content-search.feature``) +with ``package-groups/``, ``errata/``, and ``module-streams/``, matching the query behavior of +their ``content-views/{pk}/search/rpm/...`` counterparts. As with the existing ``packages/`` +endpoint, repository versions are passed directly as repeated ``repository_version`` query +parameters, with no persisted ``ContentView`` required. diff --git a/docs/rpm-content-search.md b/docs/rpm-content-search.md new file mode 100644 index 00000000..492d67a3 --- /dev/null +++ b/docs/rpm-content-search.md @@ -0,0 +1,167 @@ +# Ad-hoc RPM Content Search + +`rpm/content-search/` is a set of lightweight, cross-domain RPM search endpoints that +take repository version hrefs directly in the request — no persisted resource +required. It's the no-setup counterpart to [Content Views](content-view.md): use it +when you just want to search a one-off set of repository versions (e.g. your own +domain plus a shared `redhat`/`community` domain) without creating and maintaining a +named Content View first. + +``` +GET /api/pulp//api/v3/rpm/content-search/packages/ +GET /api/pulp//api/v3/rpm/content-search/package-groups/ +GET /api/pulp//api/v3/rpm/content-search/errata/ +GET /api/pulp//api/v3/rpm/content-search/module-streams/ +``` + +> **Note**: no client bindings are generated for these endpoints. Call them directly +> with `curl` or `requests`. + +## How it works + +1. You pass one or more `repository_version` query parameters — hrefs (or PRNs) of + repository versions, which may span any domain you can read. +2. Each href is resolved to its repository version, enforcing per-domain read access. +3. Content from all resolved versions is searched and merged, using the same query + engine as the corresponding Content View search endpoints + (`content-views/{pk}/search/rpm/...`). +4. A version in a domain you can't read is **silently excluded** from results, not an + error. A malformed or unresolvable href, or omitting the parameter entirely, is a + `400`. + +Unlike a Content View, nothing is persisted — each request is self-contained and there +is no resource to create, inspect, or delete. + +## Packages — `rpm/content-search/packages/` + +Exact-name-filtered, NEVRA-sorted, paginated. Same query behavior as +`content-views/{pk}/search/rpm/packages/list/`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `repository_version` | Yes (at least one) | Href or PRN of a repository version to search. Repeat to search multiple versions in one request. | +| `name` | No | Exact package name filter. | +| `limit` | No | Page size. | +| `offset` | No | Pagination offset. | + +```bash +BASE_URL=https://packages.redhat.com +DOMAIN= +AUTH="-u '|:'" + +curl ${AUTH} -G "${BASE_URL}/api/pulp/${DOMAIN}/api/v3/rpm/content-search/packages/" \ + --data-urlencode "repository_version=/api/pulp/${DOMAIN}/api/v3/repositories/rpm/rpm//versions/3/" \ + --data-urlencode "repository_version=/api/pulp/redhat/api/v3/repositories/rpm/rpm//versions/7/" \ + --data-urlencode "name=bash" +``` + +Paginated response, same shape as `content-views/{pk}/search/rpm/packages/list/`: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/pulp/redhat/api/v3/content/rpm/packages/.../", + "name": "bash", + "epoch": "0", + "version": "5.2.15", + "release": "2.fc38", + "arch": "x86_64" + } + ] +} +``` + +Results are sorted by NEVRA (`name`, `version`, `release`, `arch`), with `pulp_id` as a +deterministic tie-breaker when identical NEVRA content exists across domains. + +## Package groups — `rpm/content-search/package-groups/` + +Substring match on group name, not paginated. Same query behavior as +`content-views/{pk}/search/rpm/package-groups/`. When the same `(name, id)` group +appears in multiple repository versions, the `packages` lists are unioned. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `repository_version` | Yes (at least one) | Same as above. | +| `search` | No | Case-insensitive substring filter on group name. | +| `limit` | No | Max results (1–100, default 25). | + +```bash +curl ${AUTH} -G "${BASE_URL}/api/pulp/${DOMAIN}/api/v3/rpm/content-search/package-groups/" \ + --data-urlencode "repository_version=/api/pulp/${DOMAIN}/api/v3/repositories/rpm/rpm//versions/3/" \ + --data-urlencode "search=core" +``` + +Response shape: `{"results": [...]}` (flat list, no pagination envelope). + +## Errata — `rpm/content-search/errata/` + +Full advisory/errata search with filtering, sorting, and pagination. Same query +behavior as `content-views/{pk}/search/rpm/errata/`. CVE references are included in +each result. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `repository_version` | Yes (at least one) | Same as above. | +| `search` | No | Substring match on errata `id` or `title`. | +| `type` | No | Comma-separated types: `security`, `bugfix`, `enhancement`, `newpackage`. Unknown values match an "other" catch-all bucket. | +| `severity` | No | Comma-separated severities: `critical`, `important`, `moderate`, `low`, `none`. Unknown values match an "other" catch-all bucket. | +| `sort_by` | No | Sort field; prefix with `-` for descending. Allowed: `id`, `updated_date`, `issued_date`, `severity`, `type`, `title`. | +| `limit` | No | Page size (1–1000, default 100). | +| `offset` | No | Pagination offset. | + +```bash +curl ${AUTH} -G "${BASE_URL}/api/pulp/${DOMAIN}/api/v3/rpm/content-search/errata/" \ + --data-urlencode "repository_version=/api/pulp/${DOMAIN}/api/v3/repositories/rpm/rpm//versions/3/" \ + --data-urlencode "type=security" \ + --data-urlencode "sort_by=-issued_date" +``` + +Paginated response (`count`, `next`, `previous`, `results`), same shape as +`content-views/{pk}/search/rpm/errata/`. + +## Module streams — `rpm/content-search/module-streams/` + +Module stream search with optional RPM name filter, not paginated. Same query +behavior as `content-views/{pk}/search/rpm/module-streams/`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `repository_version` | Yes (at least one) | Same as above. | +| `search` | No | Substring match on module `name` or `stream`. | +| `rpm_names` | No | Comma-separated RPM package names; only streams containing at least one are returned. | +| `sort_by` | No | Sort field; prefix with `-` for descending. Allowed: `name`, `stream`, `version`, `context`, `arch`. | +| `limit` | No | Max results (1–5000, default 100). | + +```bash +curl ${AUTH} -G "${BASE_URL}/api/pulp/${DOMAIN}/api/v3/rpm/content-search/module-streams/" \ + --data-urlencode "repository_version=/api/pulp/${DOMAIN}/api/v3/repositories/rpm/rpm//versions/3/" \ + --data-urlencode "rpm_names=nodejs" +``` + +Response shape: `{"results": [...]}` (flat list, no pagination envelope). + +## Access control + +Any authenticated user can call these endpoints — there's no resource-level role to +grant. Results are still scoped by per-domain RBAC: you only see content from +repository versions in domains you can read (`core.view_domain`). Referencing a +version in an inaccessible domain doesn't error; it's just left out of the results. + +## Errors + +| Status | Cause | +|--------|-------| +| `400` | No `repository_version` parameter supplied. | +| `400` | A `repository_version` value is malformed or doesn't resolve to a repository version. | + +## Limitations + +- **No ad-hoc equivalent for environments (`PackageEnvironment`)** — use a + [Content View](content-view.md) if you need to search those. +- **No persistence** — nothing is saved between requests; you must pass the full set + of repository versions every time. diff --git a/pulp_service/pulp_service/app/content_search_viewsets.py b/pulp_service/pulp_service/app/content_search_viewsets.py index 2b2914e8..2cde0a55 100644 --- a/pulp_service/pulp_service/app/content_search_viewsets.py +++ b/pulp_service/pulp_service/app/content_search_viewsets.py @@ -13,15 +13,29 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework.exceptions import ValidationError +from rest_framework.response import Response from pulpcore.plugin.viewsets import NamedModelViewSet from pulp_service.app.content_view_models import ContentViewSearchScope -from pulp_service.app.content_view_serializers import ContentViewPackageSerializer +from pulp_service.app.content_view_serializers import ( + ContentViewErrataSerializer, + ContentViewModuleStreamSerializer, + ContentViewPackageGroupSerializer, + ContentViewPackageSerializer, +) from pulp_service.app.content_view_util import group_versions_by_domain, resolve_repository_versions from pulp_service.app.content_view_viewsets import ( + ERRATA_SEVERITY_PARAMETER, + ERRATA_TYPE_PARAMETER, LIMIT_PARAMETER, OFFSET_PARAMETER, + RPM_NAMES_PARAMETER, + SEARCH_PARAMETER, + SORT_BY_PARAMETER, + errata_search, + module_stream_search, + package_group_search, paginated_package_search, paginated_response, ) @@ -95,3 +109,96 @@ def list(self, request, *args, **kwargs): page, total, limit, offset = paginated_package_search(request, versions_by_domain) serializer = self.get_serializer(page, many=True) return paginated_response(request, serializer.data, total, limit, offset) + + +@extend_schema_view( + list=extend_schema( + parameters=[ + REPOSITORY_VERSION_PARAMETER, + SEARCH_PARAMETER, + LIMIT_PARAMETER, + ], + ) +) +class RpmContentSearchPackageGroupsViewSet(RpmContentSearchViewSet): + """ + Ad-hoc, cross-domain RPM package-group search: substring match, with the ``packages`` list of + same-keyed groups found in multiple repository versions unioned together. + + Same query behavior as the ContentView-backed ``search/rpm/package-groups`` endpoint, but the + repository versions to search come directly from ``repository_version`` query parameters + instead of a persisted ContentView's Distributions. + """ + + endpoint_name = "rpm/content-search/package-groups" + serializer_class = ContentViewPackageGroupSerializer + + 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}) + + +@extend_schema_view( + list=extend_schema( + parameters=[ + REPOSITORY_VERSION_PARAMETER, + SEARCH_PARAMETER, + LIMIT_PARAMETER, + OFFSET_PARAMETER, + SORT_BY_PARAMETER, + ERRATA_TYPE_PARAMETER, + ERRATA_SEVERITY_PARAMETER, + ], + ) +) +class RpmContentSearchErrataViewSet(RpmContentSearchViewSet): + """ + Ad-hoc, cross-domain errata (advisory) search: filter/sort/paginate, with CVE references + included. + + Same query behavior as the ContentView-backed ``search/rpm/errata`` endpoint, but the + repository versions to search come directly from ``repository_version`` query parameters + instead of a persisted ContentView's Distributions. + """ + + endpoint_name = "rpm/content-search/errata" + serializer_class = ContentViewErrataSerializer + + def list(self, request, *args, **kwargs): + versions_by_domain = self._versions_by_domain(request) + page, total, limit, offset = errata_search(request, versions_by_domain) + serializer = self.get_serializer(page, many=True) + return paginated_response(request, serializer.data, total, limit, offset) + + +@extend_schema_view( + list=extend_schema( + parameters=[ + REPOSITORY_VERSION_PARAMETER, + SEARCH_PARAMETER, + LIMIT_PARAMETER, + SORT_BY_PARAMETER, + RPM_NAMES_PARAMETER, + ], + ) +) +class RpmContentSearchModuleStreamsViewSet(RpmContentSearchViewSet): + """ + Ad-hoc, cross-domain module stream search: name/stream substring match, optional RPM name + filter, capped. + + Same query behavior as the ContentView-backed ``search/rpm/module-streams`` endpoint, but the + repository versions to search come directly from ``repository_version`` query parameters + instead of a persisted ContentView's Distributions. + """ + + endpoint_name = "rpm/content-search/module-streams" + serializer_class = ContentViewModuleStreamSerializer + + def list(self, request, *args, **kwargs): + versions_by_domain = self._versions_by_domain(request) + page = module_stream_search(request, versions_by_domain) + serializer = self.get_serializer(page, many=True) + return Response({"results": serializer.data}) diff --git a/pulp_service/pulp_service/app/content_view_viewsets.py b/pulp_service/pulp_service/app/content_view_viewsets.py index 0a519a58..bff8b253 100644 --- a/pulp_service/pulp_service/app/content_view_viewsets.py +++ b/pulp_service/pulp_service/app/content_view_viewsets.py @@ -317,6 +317,55 @@ def build_queryset(versions): return Response({"results": serializer.data}) +def package_group_search(request, versions_by_domain): + """ + Typeahead package-group search across ``versions_by_domain``: substring match, with the + ``packages`` list of same-keyed groups found in multiple domains/repository versions unioned + together. + + Shared by the ContentView-backed ``search/rpm/package-groups`` endpoint and the ad-hoc + ``rpm/content-search/package-groups`` endpoint (content_search_viewsets.py). + + Returns: + list: merged, sorted PackageGroup instances, ready for serialization. + """ + search = request.query_params.get("search", "") + 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) + return qs.order_by("name", "id") + + merged = {} + order = [] + for domain, versions in versions_by_domain.items(): + with with_domain(domain): + base_qs = build_queryset(versions) + # This queryset intentionally isn't DB-deduplicated (unlike the packages/ + # environments typeahead endpoints above) because duplicate (name, id) rows -- + # from multiple repository versions linked in this domain -- need their + # `packages` lists unioned below, not discarded. So identify the true top + # `limit` *distinct* keys cheaply via DISTINCT ON first (same exact k-way-merge + # bound as above: any key ranked <= `limit` globally is ranked <= `limit` within + # every domain's own distinct, sorted key list), then re-fetch every row -- not + # just the first -- matching those specific keys to compute a correct union. + top_keys = list(base_qs.distinct("name", "id").values_list("name", "id")[:limit]) + if not top_keys: + continue + key_filter = functools.reduce(operator.or_, (Q(name=name, id=id_) for name, id_ in top_keys)) + for group in base_qs.filter(key_filter): + key = (group.name, group.id) + if key not in merged: + merged[key] = group + 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]] + + @extend_schema_view( list=extend_schema( parameters=[ @@ -336,41 +385,7 @@ class RpmContentViewPackageGroupSearchViewSet(ContentViewSearchViewSet): def list(self, request, *args, **kwargs): versions_by_domain = self._versions_by_domain() - search = request.query_params.get("search", "") - 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) - return qs.order_by("name", "id") - - merged = {} - order = [] - for domain, versions in versions_by_domain.items(): - with with_domain(domain): - base_qs = build_queryset(versions) - # This queryset intentionally isn't DB-deduplicated (unlike the packages/ - # environments typeahead endpoints above) because duplicate (name, id) rows -- - # from multiple repository versions linked in this domain -- need their - # `packages` lists unioned below, not discarded. So identify the true top - # `limit` *distinct* keys cheaply via DISTINCT ON first (same exact k-way-merge - # bound as above: any key ranked <= `limit` globally is ranked <= `limit` within - # every domain's own distinct, sorted key list), then re-fetch every row -- not - # just the first -- matching those specific keys to compute a correct union. - top_keys = list(base_qs.distinct("name", "id").values_list("name", "id")[:limit]) - if not top_keys: - continue - key_filter = functools.reduce(operator.or_, (Q(name=name, id=id_) for name, id_ in top_keys)) - for group in base_qs.filter(key_filter): - key = (group.name, group.id) - if key not in merged: - merged[key] = group - order.append(key) - else: - merged[key].packages = _union_package_lists(merged[key].packages, group.packages) - order.sort(key=lambda key: key[0]) - page = [merged[key] for key in order[:limit]] + page = package_group_search(request, versions_by_domain) serializer = self.get_serializer(page, many=True) return Response({"results": serializer.data}) @@ -418,6 +433,74 @@ def build_queryset(versions): return Response({"results": serializer.data}) +ERRATA_TYPE_PARAMETER = OpenApiParameter( + name="type", + description=( + "Comma-separated list of errata types to include " + f"(known values: {sorted(KNOWN_ERRATA_TYPES)}; anything else matches " + "as an 'other' catch-all bucket)." + ), + required=False, + type=str, +) +ERRATA_SEVERITY_PARAMETER = OpenApiParameter( + name="severity", + description=( + "Comma-separated list of errata severities to include " + f"(known values: {sorted(KNOWN_ERRATA_SEVERITIES)}; anything else matches " + "as an 'other' catch-all bucket)." + ), + required=False, + type=str, +) + + +def errata_search(request, versions_by_domain): + """ + Full errata (advisory) search across ``versions_by_domain``: filter/sort/paginate, with CVE + references included. + + Shared by the ContentView-backed ``search/rpm/errata`` endpoint and the ad-hoc + ``rpm/content-search/errata`` endpoint (content_search_viewsets.py). + + Returns: + tuple: ``(page, total, limit, offset)``, ready for ``paginated_response``. + """ + search = request.query_params.get("search", "") + types = _list_param(request, "type") + severities = _list_param(request, "severity") + limit = _int_param(request, "limit", default=100, minimum=1, maximum=1000) + offset = _int_param(request, "offset", default=0, minimum=0, maximum=MAX_OFFSET) + + sort_by = request.query_params.get("sort_by", "id") + descending = sort_by.startswith("-") + sort_field = sort_by[1:] if descending else sort_by + if sort_field not in ERRATA_SORT_FIELDS: + sort_field = "id" + + def build_queryset(versions): + qs = content_for_versions(UpdateRecord, versions).prefetch_related( + Prefetch("references", queryset=UpdateReference.objects.filter(ref_type="cve")) + ) + if search: + qs = qs.filter(Q(id__icontains=search) | Q(title__icontains=search)) + if types: + qs = qs.filter(_catch_all_filter("type", types, KNOWN_ERRATA_TYPES)) + if severities: + qs = qs.filter(_catch_all_filter("severity", severities, KNOWN_ERRATA_SEVERITIES)) + return qs.order_by(sort_field) + + page, total = scatter_gather( + versions_by_domain, + build_queryset, + order_by=(sort_field,), + limit=limit, + offset=offset, + descending=descending, + ) + return page, total, limit, offset + + @extend_schema_view( list=extend_schema( parameters=[ @@ -425,26 +508,8 @@ def build_queryset(versions): LIMIT_PARAMETER, OFFSET_PARAMETER, SORT_BY_PARAMETER, - OpenApiParameter( - name="type", - description=( - "Comma-separated list of errata types to include " - f"(known values: {sorted(KNOWN_ERRATA_TYPES)}; anything else matches " - "as an 'other' catch-all bucket)." - ), - required=False, - type=str, - ), - OpenApiParameter( - name="severity", - description=( - "Comma-separated list of errata severities to include " - f"(known values: {sorted(KNOWN_ERRATA_SEVERITIES)}; anything else matches " - "as an 'other' catch-all bucket)." - ), - required=False, - type=str, - ), + ERRATA_TYPE_PARAMETER, + ERRATA_SEVERITY_PARAMETER, ], ) ) @@ -456,55 +521,64 @@ class RpmContentViewErrataViewSet(ContentViewSearchViewSet): def list(self, request, *args, **kwargs): versions_by_domain = self._versions_by_domain() - search = request.query_params.get("search", "") - types = _list_param(request, "type") - severities = _list_param(request, "severity") - limit = _int_param(request, "limit", default=100, minimum=1, maximum=1000) - offset = _int_param(request, "offset", default=0, minimum=0, maximum=MAX_OFFSET) - - sort_by = request.query_params.get("sort_by", "id") - descending = sort_by.startswith("-") - sort_field = sort_by[1:] if descending else sort_by - if sort_field not in ERRATA_SORT_FIELDS: - sort_field = "id" - - def build_queryset(versions): - qs = content_for_versions(UpdateRecord, versions).prefetch_related( - Prefetch("references", queryset=UpdateReference.objects.filter(ref_type="cve")) - ) - if search: - qs = qs.filter(Q(id__icontains=search) | Q(title__icontains=search)) - if types: - qs = qs.filter(_catch_all_filter("type", types, KNOWN_ERRATA_TYPES)) - if severities: - qs = qs.filter(_catch_all_filter("severity", severities, KNOWN_ERRATA_SEVERITIES)) - return qs.order_by(sort_field) - - page, total = scatter_gather( - versions_by_domain, - build_queryset, - order_by=(sort_field,), - limit=limit, - offset=offset, - descending=descending, - ) + page, total, limit, offset = errata_search(request, versions_by_domain) serializer = self.get_serializer(page, many=True) return paginated_response(request, serializer.data, total, limit, offset) +RPM_NAMES_PARAMETER = OpenApiParameter( + name="rpm_names", + description="Comma-separated list of RPM package names; only module streams " + "containing at least one of these packages are returned.", + required=False, + type=str, +) + + +def module_stream_search(request, versions_by_domain): + """ + Module stream search across ``versions_by_domain``: name/stream substring match, optional RPM + name filter, capped. + + Shared by the ContentView-backed ``search/rpm/module-streams`` endpoint and the ad-hoc + ``rpm/content-search/module-streams`` endpoint (content_search_viewsets.py). + + Returns: + list: sorted Modulemd instances, ready for serialization. + """ + search = request.query_params.get("search", "") + rpm_names = _list_param(request, "rpm_names") + limit = _int_param(request, "limit", default=100, minimum=1, maximum=MODULE_STREAMS_HARD_CAP) + + sort_by = request.query_params.get("sort_by", "name") + descending = sort_by.startswith("-") + sort_field = sort_by[1:] if descending else sort_by + if sort_field not in MODULE_STREAM_SORT_FIELDS: + sort_field = "name" + + def build_queryset(versions): + qs = content_for_versions(Modulemd, versions) + if search: + qs = qs.filter(Q(name__icontains=search) | Q(stream__icontains=search)) + if rpm_names: + qs = qs.filter(packages__name__in=rpm_names).distinct() + return qs.order_by(sort_field) + + 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] + + @extend_schema_view( list=extend_schema( parameters=[ SEARCH_PARAMETER, LIMIT_PARAMETER, SORT_BY_PARAMETER, - OpenApiParameter( - name="rpm_names", - description="Comma-separated list of RPM package names; only module streams " - "containing at least one of these packages are returned.", - required=False, - type=str, - ), + RPM_NAMES_PARAMETER, ], ) ) @@ -516,30 +590,7 @@ class RpmContentViewModuleStreamsViewSet(ContentViewSearchViewSet): def list(self, request, *args, **kwargs): versions_by_domain = self._versions_by_domain() - search = request.query_params.get("search", "") - rpm_names = _list_param(request, "rpm_names") - limit = _int_param(request, "limit", default=100, minimum=1, maximum=MODULE_STREAMS_HARD_CAP) - - sort_by = request.query_params.get("sort_by", "name") - descending = sort_by.startswith("-") - sort_field = sort_by[1:] if descending else sort_by - if sort_field not in MODULE_STREAM_SORT_FIELDS: - sort_field = "name" - - def build_queryset(versions): - qs = content_for_versions(Modulemd, versions) - if search: - qs = qs.filter(Q(name__icontains=search) | Q(stream__icontains=search)) - if rpm_names: - qs = qs.filter(packages__name__in=rpm_names).distinct() - return qs.order_by(sort_field) - - 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) - page = rows[:limit] + page = module_stream_search(request, versions_by_domain) serializer = self.get_serializer(page, many=True) return Response({"results": serializer.data}) diff --git a/pulp_service/pulp_service/app/viewsets.py b/pulp_service/pulp_service/app/viewsets.py index 383d429e..34fc83fd 100755 --- a/pulp_service/pulp_service/app/viewsets.py +++ b/pulp_service/pulp_service/app/viewsets.py @@ -48,7 +48,12 @@ set_domain_create_context, user_id_var, ) -from pulp_service.app.content_search_viewsets import RpmContentSearchPackagesViewSet # noqa: F401 +from pulp_service.app.content_search_viewsets import ( # noqa: F401 + RpmContentSearchErrataViewSet, + RpmContentSearchModuleStreamsViewSet, + RpmContentSearchPackageGroupsViewSet, + RpmContentSearchPackagesViewSet, +) from pulp_service.app.content_view_viewsets import ( # noqa: F401 ContentViewFilter, ContentViewViewSet, diff --git a/pulp_service/pulp_service/tests/functional/test_rpm_content_search.py b/pulp_service/pulp_service/tests/functional/test_rpm_content_search.py index 290f76e7..f794326f 100644 --- a/pulp_service/pulp_service/tests/functional/test_rpm_content_search.py +++ b/pulp_service/pulp_service/tests/functional/test_rpm_content_search.py @@ -1,9 +1,9 @@ """ -Functional tests for the ad-hoc ``rpm/content-search/packages/`` endpoint -- the -no-ContentView-required counterpart to ``content-views/{pk}/search/rpm/packages/list``. +Functional tests for the ad-hoc ``rpm/content-search/...`` endpoints -- the no-ContentView- +required counterparts to ``content-views/{pk}/search/rpm/...``. -No client bindings exist for this endpoint (it isn't tied to a generated resource), so these -hit it directly with ``requests``, mirroring the pattern used in test_rbac_endpoint_access.py. +No client bindings exist for these endpoints (they aren't tied to a generated resource), so these +hit them directly with ``requests``, mirroring the pattern used in test_rbac_endpoint_access.py. """ from urllib.parse import urljoin @@ -11,7 +11,7 @@ import pytest import requests -from pulp_rpm.tests.functional.constants import RPM_PACKAGE_COUNT +from pulp_rpm.tests.functional.constants import RPM_ADVISORY_COUNT, RPM_PACKAGE_COUNT @pytest.fixture @@ -19,8 +19,8 @@ def admin_auth(bindings_cfg): return (bindings_cfg.username, bindings_cfg.password) -def _search_url(bindings_cfg, domain): - return urljoin(bindings_cfg.host, f"/api/pulp/{domain.name}/api/v3/rpm/content-search/packages/") +def _search_url(bindings_cfg, domain, endpoint="packages"): + return urljoin(bindings_cfg.host, f"/api/pulp/{domain.name}/api/v3/rpm/content-search/{endpoint}/") @pytest.mark.parallel @@ -100,6 +100,93 @@ def test_malformed_repository_version_href_is_400(self, setup_domain, bindings_c assert resp.status_code == 400, resp.text +@pytest.mark.parallel +class TestRpmContentSearchPackageGroups: + def test_search_aggregates_across_domains(self, setup_domain, bindings_cfg, admin_auth): + domain_a, _remote_a, src_a, _dest_a = setup_domain() + _domain_b, _remote_b, src_b, _dest_b = setup_domain() + + resp = requests.get( + _search_url(bindings_cfg, domain_a, endpoint="package-groups"), + params={"repository_version": [src_a.latest_version_href, src_b.latest_version_href]}, + auth=admin_auth, + 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): + domain, _remote, _src, _dest = setup_domain(sync=False) + + resp = requests.get(_search_url(bindings_cfg, domain, endpoint="package-groups"), auth=admin_auth, timeout=60) + assert resp.status_code == 400, resp.text + + +@pytest.mark.parallel +class TestRpmContentSearchErrata: + def test_search_aggregates_across_domains(self, setup_domain, bindings_cfg, admin_auth): + domain_a, _remote_a, src_a, _dest_a = setup_domain() + _domain_b, _remote_b, src_b, _dest_b = setup_domain() + + resp = requests.get( + _search_url(bindings_cfg, domain_a, endpoint="errata"), + params={"repository_version": [src_a.latest_version_href, src_b.latest_version_href]}, + auth=admin_auth, + timeout=60, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["count"] == 2 * RPM_ADVISORY_COUNT + + def test_type_filter(self, setup_domain, bindings_cfg, admin_auth): + domain, _remote, src, _dest = setup_domain() + + all_errata = requests.get( + _search_url(bindings_cfg, domain, endpoint="errata"), + params={"repository_version": src.latest_version_href}, + auth=admin_auth, + timeout=60, + ).json() + known_type = all_errata["results"][0]["type"] + + filtered = requests.get( + _search_url(bindings_cfg, domain, endpoint="errata"), + params={"repository_version": src.latest_version_href, "type": known_type}, + 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): + domain, _remote, _src, _dest = setup_domain(sync=False) + + resp = requests.get(_search_url(bindings_cfg, domain, endpoint="errata"), auth=admin_auth, timeout=60) + assert resp.status_code == 400, resp.text + + +@pytest.mark.parallel +class TestRpmContentSearchModuleStreams: + def test_search_responds_ok(self, setup_domain, bindings_cfg, admin_auth): + # The default fixture repo used by `setup_domain` has no module streams; this just + # verifies the endpoint responds correctly (empty results) rather than erroring. + domain, _remote, src, _dest = setup_domain() + + resp = requests.get( + _search_url(bindings_cfg, domain, endpoint="module-streams"), + params={"repository_version": src.latest_version_href}, + auth=admin_auth, + timeout=60, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["results"] == [] + + def test_missing_repository_version_param_is_400(self, setup_domain, bindings_cfg, admin_auth): + domain, _remote, _src, _dest = setup_domain(sync=False) + + resp = requests.get(_search_url(bindings_cfg, domain, endpoint="module-streams"), auth=admin_auth, timeout=60) + assert resp.status_code == 400, resp.text + + @pytest.mark.parallel class TestRpmContentSearchRBACExclusion: def test_inaccessible_domain_silently_excluded(self, setup_domain, bindings_cfg, gen_user):