Skip to content

Commit ff944ee

Browse files
committed
Adds synchronous RPM Upload API
closes: #4027
1 parent ee9f0e5 commit ff944ee

8 files changed

Lines changed: 161 additions & 3 deletions

File tree

CHANGES/4027.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a synchronous RPM upload API. It's available at /pulp/api/v3/content/rpm/packages/upload/.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.10 on 2025-07-01 18:09
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('rpm', '0064_remove_rpmrepository_original_checksum_types_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='package',
15+
options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('upload_rpm_packages', 'Can upload RPM packages using synchronous API.')]},
16+
),
17+
]

pulp_rpm/app/models/package.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ class Meta:
299299
"checksum_type",
300300
"pkgId",
301301
)
302+
permissions = [
303+
("upload_rpm_packages", "Can upload RPM packages using synchronous API."),
304+
]
302305

303306
class ReadonlyMeta:
304307
readonly = ["evr"]

pulp_rpm/app/serializers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
ModulemdDefaultsSerializer,
2727
ModulemdObsoleteSerializer,
2828
)
29-
from .package import PackageSerializer, MinimalPackageSerializer # noqa
29+
from .package import PackageSerializer, PackageUploadSerializer, MinimalPackageSerializer # noqa
3030
from .prune import PrunePackagesSerializer # noqa
3131
from .repository import ( # noqa
3232
CopySerializer,

pulp_rpm/app/serializers/package.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import createrepo_c as cr
12
import logging
23
import traceback
34
from gettext import gettext as _
45

6+
from django.conf import settings
7+
from django.db import DatabaseError
58
from drf_spectacular.utils import extend_schema_serializer
69
from rest_framework import serializers
710
from rest_framework.exceptions import NotAcceptable
811

12+
from pulpcore.plugin.models import Artifact
913
from pulpcore.plugin.serializers import (
14+
ArtifactSerializer,
1015
ContentChecksumSerializer,
1116
SingleArtifactContentUploadSerializer,
1217
)
@@ -382,3 +387,67 @@ class Meta:
382387
"checksum_type",
383388
)
384389
model = Package
390+
391+
392+
class PackageUploadSerializer(PackageSerializer):
393+
"""
394+
Serializer for requests to synchronously upload RPM packages.
395+
"""
396+
397+
class Meta(PackageSerializer.Meta):
398+
# This API does not support uploading to a repository.
399+
# It doesn't support custom relative_path either.
400+
fields = tuple(
401+
f for f in PackageSerializer.Meta.fields if f not in ["repository", "relative_path"]
402+
)
403+
model = Package
404+
# Name used for the OpenAPI request object
405+
ref_name = "PackageUpload"
406+
407+
def validate(self, data):
408+
uploaded_file = data.get("file")
409+
# export META from rpm and prepare dict as saveable format
410+
try:
411+
cr_object = cr.package_from_rpm(
412+
uploaded_file.file.name, changelog_limit=settings.KEEP_CHANGELOG_LIMIT
413+
)
414+
new_pkg = Package.createrepo_to_dict(cr_object)
415+
except OSError as e:
416+
log.info(traceback.format_exc())
417+
raise NotAcceptable(detail="RPM file cannot be parsed for metadata") from e
418+
419+
# Get or create the Artifact
420+
if "file" in data:
421+
file = data.pop("file")
422+
# if artifact already exists, let's use it
423+
try:
424+
artifact = Artifact.objects.get(
425+
sha256=file.hashers["sha256"].hexdigest(), pulp_domain=get_domain_pk()
426+
)
427+
if not artifact.pulp_domain.get_storage().exists(artifact.file.name):
428+
artifact.file = file
429+
artifact.save()
430+
else:
431+
artifact.touch()
432+
except (Artifact.DoesNotExist, DatabaseError):
433+
artifact_data = {"file": file}
434+
serializer = ArtifactSerializer(data=artifact_data)
435+
serializer.is_valid(raise_exception=True)
436+
artifact = serializer.save()
437+
data["artifact"] = artifact
438+
439+
filename = (
440+
format_nvra(
441+
new_pkg["name"],
442+
new_pkg["version"],
443+
new_pkg["release"],
444+
new_pkg["arch"],
445+
)
446+
+ ".rpm"
447+
)
448+
449+
data["relative_path"] = filename
450+
new_pkg["location_href"] = filename
451+
452+
data.update(new_pkg)
453+
return data

pulp_rpm/app/viewsets/package.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.db import transaction
12
from django_filters import CharFilter
23
from drf_spectacular.utils import extend_schema
34
from pulpcore.plugin.models import PulpTemporaryFile
@@ -8,10 +9,17 @@
89
OperationPostponedResponse,
910
SingleArtifactContentUploadViewSet,
1011
)
12+
from rest_framework import status
13+
from rest_framework.decorators import action
14+
from rest_framework.response import Response
1115

1216
from pulp_rpm.app import tasks as rpm_tasks
1317
from pulp_rpm.app.models import Package
14-
from pulp_rpm.app.serializers import MinimalPackageSerializer, PackageSerializer
18+
from pulp_rpm.app.serializers import (
19+
MinimalPackageSerializer,
20+
PackageSerializer,
21+
PackageUploadSerializer,
22+
)
1523

1624

1725
class PackageFilter(ContentFilter):
@@ -68,6 +76,14 @@ class PackageViewSet(SingleArtifactContentUploadViewSet):
6876
"has_required_repo_perms_on_upload:rpm.view_rpmrepository",
6977
],
7078
},
79+
{
80+
"action": ["upload"],
81+
"principal": "authenticated",
82+
"effect": "allow",
83+
"condition": [
84+
"has_model_or_domain_perms:rpm.upload_rpm_packages",
85+
],
86+
},
7187
{
7288
"action": ["set_label", "unset_label"],
7389
"principal": "authenticated",
@@ -127,3 +143,22 @@ def create(self, request):
127143
},
128144
)
129145
return OperationPostponedResponse(task, request)
146+
147+
@extend_schema(
148+
description="Synchronously upload an RPM package.",
149+
request=PackageUploadSerializer,
150+
responses={201: PackageSerializer},
151+
summary="Upload an RPM package synchronously.",
152+
)
153+
@action(detail=False, methods=["post"], serializer_class=PackageUploadSerializer)
154+
def upload(self, request):
155+
"""Create an RPM package."""
156+
serializer = self.get_serializer(data=request.data)
157+
with transaction.atomic():
158+
# Create the artifact
159+
serializer.is_valid(raise_exception=True)
160+
# Create the Package
161+
serializer.save()
162+
163+
headers = self.get_success_headers(serializer.data)
164+
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

pulp_rpm/tests/functional/api/test_upload.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,39 @@ def test_upload_comps_xml_into_repo_replace(
206206
eval_counts(vers_resp.content_summary.added, is_small=False)
207207

208208

209+
def test_synchronous_package_upload(
210+
delete_orphans_pre, rpm_package_api, monitor_task, pulpcore_bindings
211+
):
212+
"""Test synchronously uploading an RPM.
213+
214+
1. Upload a unit
215+
2. Attempt to upload same unit with different labels
216+
3. Assert that labels don't change.
217+
"""
218+
# Single unit upload
219+
file_to_use = os.path.join(RPM_UNSIGNED_FIXTURE_URL, RPM_PACKAGE_FILENAME)
220+
221+
labels = {"key_1": "value_1"}
222+
with NamedTemporaryFile() as file_to_upload:
223+
file_to_upload.write(requests.get(file_to_use).content)
224+
upload_attrs = {"file": file_to_upload.name, "pulp_labels": labels}
225+
package = rpm_package_api.upload(**upload_attrs)
226+
227+
assert package.location_href == RPM_PACKAGE_FILENAME
228+
assert package.pulp_labels == labels
229+
230+
# Duplicate unit
231+
with NamedTemporaryFile() as file_to_upload:
232+
new_labels = {"key_2": "value_2"}
233+
file_to_upload.write(requests.get(file_to_use).content)
234+
upload_attrs = {"file": file_to_upload.name, "pulp_labels": new_labels}
235+
duplicate_package = rpm_package_api.upload(**upload_attrs)
236+
237+
assert duplicate_package.pulp_href == package.pulp_href
238+
assert duplicate_package.pulp_labels == package.pulp_labels
239+
assert duplicate_package.pulp_labels != new_labels
240+
241+
209242
def eval_resources(resources, is_small=True):
210243
"""Eval created_resources counts."""
211244
groups = [g for g in resources if "packagegroups" in g]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"jsonschema>=4.6,<5.0",
3333
"libcomps>=0.1.20.post1,<0.2",
3434
"productmd~=1.33.0",
35-
"pulpcore>=3.76.0,<3.85",
35+
"pulpcore>=3.81.0,<3.85",
3636
"solv~=0.7.21",
3737
"aiohttp_xmlrpc~=1.5.0",
3838
"importlib-resources~=6.4.0",

0 commit comments

Comments
 (0)