Skip to content
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
1 change: 1 addition & 0 deletions CHANGES/4027.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a synchronous RPM upload API. It's available at /pulp/api/v3/content/rpm/packages/upload/.
17 changes: 17 additions & 0 deletions pulp_rpm/app/migrations/0065_alter_package_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2025-07-01 20:29

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('rpm', '0064_remove_rpmrepository_original_checksum_types_and_more'),
]

operations = [
migrations.AlterModelOptions(
name='package',
options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('upload_rpm_packages', 'Can upload RPM packages using synchronous API.')]},
),
]
3 changes: 3 additions & 0 deletions pulp_rpm/app/models/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ class Meta:
"checksum_type",
"pkgId",
)
permissions = [
("upload_rpm_packages", "Can upload RPM packages using synchronous API."),
]

class ReadonlyMeta:
readonly = ["evr"]
Expand Down
2 changes: 1 addition & 1 deletion pulp_rpm/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
ModulemdDefaultsSerializer,
ModulemdObsoleteSerializer,
)
from .package import PackageSerializer, MinimalPackageSerializer # noqa
from .package import PackageSerializer, PackageUploadSerializer, MinimalPackageSerializer # noqa
from .prune import PrunePackagesSerializer # noqa
from .repository import ( # noqa
CopySerializer,
Expand Down
69 changes: 69 additions & 0 deletions pulp_rpm/app/serializers/package.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import createrepo_c as cr
import logging
import traceback
from gettext import gettext as _

from django.conf import settings
from django.db import DatabaseError
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from rest_framework.exceptions import NotAcceptable

from pulpcore.plugin.models import Artifact
from pulpcore.plugin.serializers import (
ArtifactSerializer,
ContentChecksumSerializer,
SingleArtifactContentUploadSerializer,
)
Expand Down Expand Up @@ -382,3 +387,67 @@ class Meta:
"checksum_type",
)
model = Package


class PackageUploadSerializer(PackageSerializer):
"""
Serializer for requests to synchronously upload RPM packages.
"""

class Meta(PackageSerializer.Meta):
# This API does not support uploading to a repository.
# It doesn't support custom relative_path either.
fields = tuple(
f for f in PackageSerializer.Meta.fields if f not in ["repository", "relative_path"]
)
model = Package
# Name used for the OpenAPI request object
ref_name = "PackageUpload"

def validate(self, data):
uploaded_file = data.get("file")
# export META from rpm and prepare dict as saveable format
try:
cr_object = cr.package_from_rpm(
uploaded_file.file.name, changelog_limit=settings.KEEP_CHANGELOG_LIMIT
)
new_pkg = Package.createrepo_to_dict(cr_object)
except OSError as e:
log.info(traceback.format_exc())
raise NotAcceptable(detail="RPM file cannot be parsed for metadata") from e

# Get or create the Artifact
if "file" in data:
file = data.pop("file")
# if artifact already exists, let's use it
try:
artifact = Artifact.objects.get(
sha256=file.hashers["sha256"].hexdigest(), pulp_domain=get_domain_pk()
)
if not artifact.pulp_domain.get_storage().exists(artifact.file.name):
artifact.file = file
artifact.save()
else:
artifact.touch()
except (Artifact.DoesNotExist, DatabaseError):
artifact_data = {"file": file}
serializer = ArtifactSerializer(data=artifact_data)
serializer.is_valid(raise_exception=True)
artifact = serializer.save()
data["artifact"] = artifact

filename = (
format_nvra(
new_pkg["name"],
new_pkg["version"],
new_pkg["release"],
new_pkg["arch"],
)
+ ".rpm"
)

data["relative_path"] = filename
new_pkg["location_href"] = filename

data.update(new_pkg)
return data
43 changes: 42 additions & 1 deletion pulp_rpm/app/viewsets/package.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.db import transaction
from django_filters import CharFilter
from drf_spectacular.utils import extend_schema
from pulpcore.plugin.models import PulpTemporaryFile
Expand All @@ -8,10 +9,17 @@
OperationPostponedResponse,
SingleArtifactContentUploadViewSet,
)
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response

from pulp_rpm.app import tasks as rpm_tasks
from pulp_rpm.app.models import Package
from pulp_rpm.app.serializers import MinimalPackageSerializer, PackageSerializer
from pulp_rpm.app.serializers import (
MinimalPackageSerializer,
PackageSerializer,
PackageUploadSerializer,
)


class PackageFilter(ContentFilter):
Expand Down Expand Up @@ -68,6 +76,14 @@ class PackageViewSet(SingleArtifactContentUploadViewSet):
"has_required_repo_perms_on_upload:rpm.view_rpmrepository",
],
},
{
"action": ["upload"],
"principal": "authenticated",
"effect": "allow",
"condition": [
"has_model_or_domain_perms:rpm.upload_rpm_packages",
],
},
{
"action": ["set_label", "unset_label"],
"principal": "authenticated",
Expand All @@ -80,6 +96,12 @@ class PackageViewSet(SingleArtifactContentUploadViewSet):
"queryset_scoping": {"function": "scope_queryset"},
}

LOCKED_ROLES = {
"rpm.rpm_package_uploader": [
"rpm.upload_rpm_packages",
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.

👍

],
}

@extend_schema(
description="Trigger an asynchronous task to create an RPM package,"
"optionally create new repository version.",
Expand Down Expand Up @@ -127,3 +149,22 @@ def create(self, request):
},
)
return OperationPostponedResponse(task, request)

@extend_schema(
description="Synchronously upload an RPM package.",
request=PackageUploadSerializer,
responses={201: PackageSerializer},
summary="Upload an RPM package synchronously.",
)
@action(detail=False, methods=["post"], serializer_class=PackageUploadSerializer)
def upload(self, request):
"""Create an RPM package."""
serializer = self.get_serializer(data=request.data)
with transaction.atomic():
# Create the artifact
serializer.is_valid(raise_exception=True)
# Create the Package
serializer.save()

headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
41 changes: 41 additions & 0 deletions pulp_rpm/tests/functional/api/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
import requests

from pulpcore.client.pulp_rpm import ApiException
from pulpcore.tests.functional.utils import PulpTaskError
from pulp_rpm.tests.functional.constants import (
BIG_COMPS_XML,
Expand Down Expand Up @@ -206,6 +207,46 @@ def test_upload_comps_xml_into_repo_replace(
eval_counts(vers_resp.content_summary.added, is_small=False)


def test_synchronous_package_upload(delete_orphans_pre, rpm_package_api, gen_user):
"""Test synchronously uploading an RPM.

1. Upload a unit
2. Attempt to upload same unit with different labels
3. Assert that labels don't change.
"""
# Single unit upload
file_to_use = os.path.join(RPM_UNSIGNED_FIXTURE_URL, RPM_PACKAGE_FILENAME)

with gen_user(model_roles=["rpm.rpm_package_uploader"]):
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.

👍

labels = {"key_1": "value_1"}
with NamedTemporaryFile() as file_to_upload:
file_to_upload.write(requests.get(file_to_use).content)
upload_attrs = {"file": file_to_upload.name, "pulp_labels": labels}
package = rpm_package_api.upload(**upload_attrs)

assert package.location_href == RPM_PACKAGE_FILENAME
assert package.pulp_labels == labels

# Duplicate unit
with NamedTemporaryFile() as file_to_upload:
new_labels = {"key_2": "value_2"}
file_to_upload.write(requests.get(file_to_use).content)
upload_attrs = {"file": file_to_upload.name, "pulp_labels": new_labels}
duplicate_package = rpm_package_api.upload(**upload_attrs)

assert duplicate_package.pulp_href == package.pulp_href
assert duplicate_package.pulp_labels == package.pulp_labels
assert duplicate_package.pulp_labels != new_labels

with gen_user(model_roles=[]), pytest.raises(ApiException) as ctx:
labels = {"key_1": "value_1"}
with NamedTemporaryFile() as file_to_upload:
file_to_upload.write(requests.get(file_to_use).content)
upload_attrs = {"file": file_to_upload.name, "pulp_labels": labels}
rpm_package_api.upload(**upload_attrs)
assert ctx.value.status == 403
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.

👍



def eval_resources(resources, is_small=True):
"""Eval created_resources counts."""
groups = [g for g in resources if "packagegroups" in g]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies = [
"jsonschema>=4.6,<5.0",
"libcomps>=0.1.20.post1,<0.2",
"productmd~=1.33.0",
"pulpcore>=3.76.0,<3.85",
"pulpcore>=3.81.0,<3.85",
"solv~=0.7.21",
"aiohttp_xmlrpc~=1.5.0",
"importlib-resources~=6.4.0",
Expand Down