Skip to content

Commit a9bbe13

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

8 files changed

Lines changed: 176 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 20:29
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: 42 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",
@@ -80,6 +96,12 @@ class PackageViewSet(SingleArtifactContentUploadViewSet):
8096
"queryset_scoping": {"function": "scope_queryset"},
8197
}
8298

99+
LOCKED_ROLES = {
100+
"rpm.rpm_package_uploader": [
101+
"rpm.upload_rpm_packages",
102+
],
103+
}
104+
83105
@extend_schema(
84106
description="Trigger an asynchronous task to create an RPM package,"
85107
"optionally create new repository version.",
@@ -127,3 +149,22 @@ def create(self, request):
127149
},
128150
)
129151
return OperationPostponedResponse(task, request)
152+
153+
@extend_schema(
154+
description="Synchronously upload an RPM package.",
155+
request=PackageUploadSerializer,
156+
responses={201: PackageSerializer},
157+
summary="Upload an RPM package synchronously.",
158+
)
159+
@action(detail=False, methods=["post"], serializer_class=PackageUploadSerializer)
160+
def upload(self, request):
161+
"""Create an RPM package."""
162+
serializer = self.get_serializer(data=request.data)
163+
with transaction.atomic():
164+
# Create the artifact
165+
serializer.is_valid(raise_exception=True)
166+
# Create the Package
167+
serializer.save()
168+
169+
headers = self.get_success_headers(serializer.data)
170+
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

pulp_rpm/tests/functional/api/test_upload.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77
import requests
88

9+
from pulpcore.client.pulp_rpm import ApiException
910
from pulpcore.tests.functional.utils import PulpTaskError
1011
from pulp_rpm.tests.functional.constants import (
1112
BIG_COMPS_XML,
@@ -206,6 +207,47 @@ def test_upload_comps_xml_into_repo_replace(
206207
eval_counts(vers_resp.content_summary.added, is_small=False)
207208

208209

210+
def test_synchronous_package_upload(
211+
delete_orphans_pre, rpm_package_api, gen_user
212+
):
213+
"""Test synchronously uploading an RPM.
214+
215+
1. Upload a unit
216+
2. Attempt to upload same unit with different labels
217+
3. Assert that labels don't change.
218+
"""
219+
# Single unit upload
220+
file_to_use = os.path.join(RPM_UNSIGNED_FIXTURE_URL, RPM_PACKAGE_FILENAME)
221+
222+
with gen_user(model_roles=["rpm.rpm_package_uploader"]):
223+
labels = {"key_1": "value_1"}
224+
with NamedTemporaryFile() as file_to_upload:
225+
file_to_upload.write(requests.get(file_to_use).content)
226+
upload_attrs = {"file": file_to_upload.name, "pulp_labels": labels}
227+
package = rpm_package_api.upload(**upload_attrs)
228+
229+
assert package.location_href == RPM_PACKAGE_FILENAME
230+
assert package.pulp_labels == labels
231+
232+
# Duplicate unit
233+
with NamedTemporaryFile() as file_to_upload:
234+
new_labels = {"key_2": "value_2"}
235+
file_to_upload.write(requests.get(file_to_use).content)
236+
upload_attrs = {"file": file_to_upload.name, "pulp_labels": new_labels}
237+
duplicate_package = rpm_package_api.upload(**upload_attrs)
238+
239+
assert duplicate_package.pulp_href == package.pulp_href
240+
assert duplicate_package.pulp_labels == package.pulp_labels
241+
assert duplicate_package.pulp_labels != new_labels
242+
243+
with gen_user(model_roles=[]), pytest.raises(ApiException) as ctx:
244+
labels = {"key_1": "value_1"}
245+
with NamedTemporaryFile() as file_to_upload:
246+
file_to_upload.write(requests.get(file_to_use).content)
247+
upload_attrs = {"file": file_to_upload.name, "pulp_labels": labels}
248+
rpm_package_api.upload(**upload_attrs)
249+
assert ctx.value.status == 403
250+
209251
def eval_resources(resources, is_small=True):
210252
"""Eval created_resources counts."""
211253
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)