Skip to content

Commit 8eb6122

Browse files
ggaineydralley
andcommitted
Enable API-versioning and allow for both v3 and v4 versions.
This is a tech-preview PR, and will only "turn on" if the ENABLE_V4_API is set to True in settings. It will allow urls for /v3/ and /v4/, and reject other versions. For urlpatterns registered with a `/<str:version>/` slug, views will be called with a `version=` kwarg. NOTE: To play this game, plugins will need to insure that all views accept `**kwargs` first, and then update the patterns in their `urls.py` to include the version-slug. See the `/status/` view and serializer(s) for an example of how to respond based on the incoming request. NOTE to implementers: **existing tests must pass without changes** whether the ENABLE flag is True or False. If that isn't the case - you're doing something wrong. closes #6462 Co-authored-by: Daniel Alley <dalley@redhat.com>
1 parent dfd517c commit 8eb6122

48 files changed

Lines changed: 482 additions & 224 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGES/6462.feature

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Add `/v4/` API to Pulp.
2+
3+
This adds a `/v4/` API path to Pulp, in parallel to the existing `/v3/` path. The two
4+
are currently (nearly) identical APIs - see the `/pulp/api/v4/status/` ouput for the
5+
only (current) end-user-visible impact.
6+
7+
This change is primarily setting the stage to allow for future API changes and growth.
8+
It is in TECH PREVIEW, and is likely to have significant changes happening to it as we
9+
continue integrating into the rest of the Pulp architecture.

pulp_file/app/tasks/publishing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
log = logging.getLogger(__name__)
1919

2020

21-
def publish(manifest, repository_version_pk, checkpoint=False):
21+
def publish(manifest, repository_version_pk, checkpoint=False, **kwargs):
2222
"""
2323
Create a Publication based on a RepositoryVersion.
2424

pulp_file/app/tasks/synchronizing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def synchronize(remote_pk, repository_pk, mirror, optimize=False, url=None, **kw
111111
ValueError: If the remote does not specify a URL to sync.
112112
113113
"""
114+
114115
remote = Remote.objects.get(pk=remote_pk).cast()
115116
repository = FileRepository.objects.get(pk=repository_pk)
116117

pulp_file/app/viewsets.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class FileContentViewSet(SingleArtifactContentUploadViewSet):
135135
summary="Upload a File synchronously.",
136136
)
137137
@action(detail=False, methods=["post"], serializer_class=FileContentUploadSerializer)
138-
def upload(self, request):
138+
def upload(self, request, **kwargs):
139139
"""Create a File."""
140140
serializer = self.get_serializer(data=request.data)
141141
with transaction.atomic():
@@ -258,7 +258,7 @@ class FileRepositoryViewSet(RepositoryViewSet, ModifyRepositoryActionMixin, Role
258258
responses={202: AsyncOperationResponseSerializer},
259259
)
260260
@action(detail=True, methods=["post"], serializer_class=FileRepositorySyncURLSerializer)
261-
def sync(self, request, pk):
261+
def sync(self, request, pk, **kwargs):
262262
"""
263263
Synchronizes a repository.
264264
@@ -276,16 +276,19 @@ def sync(self, request, pk):
276276
optimize = serializer.validated_data.get("optimize", True) # noqa
277277
if mirror and repository.autopublish:
278278
raise ValidationError("Cannot use mirror mode with autopublished repository.")
279+
280+
task_kwargs = {
281+
"remote_pk": str(remote.pk),
282+
"repository_pk": str(repository.pk),
283+
"mirror": mirror,
284+
"optimize": optimize,
285+
}
286+
279287
result = dispatch(
280288
tasks.synchronize,
281289
shared_resources=[remote],
282290
exclusive_resources=[repository],
283-
kwargs={
284-
"remote_pk": str(remote.pk),
285-
"repository_pk": str(repository.pk),
286-
"mirror": mirror,
287-
"optimize": optimize,
288-
},
291+
kwargs=task_kwargs,
289292
)
290293
return OperationPostponedResponse(result, request)
291294

@@ -559,7 +562,7 @@ class FilePublicationViewSet(PublicationViewSet, RolesMixin):
559562
description="Trigger an asynchronous task to publish file content.",
560563
responses={202: AsyncOperationResponseSerializer},
561564
)
562-
def create(self, request):
565+
def create(self, request, **kwargs):
563566
"""
564567
Publishes a repository.
565568
@@ -572,13 +575,15 @@ def create(self, request):
572575
manifest = serializer.validated_data.get("manifest")
573576
checkpoint = serializer.validated_data.get("checkpoint")
574577

575-
kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}
578+
task_kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}
579+
576580
if checkpoint:
577-
kwargs["checkpoint"] = True
581+
task_kwargs["checkpoint"] = True
582+
578583
result = dispatch(
579584
tasks.publish,
580585
shared_resources=[repository_version.repository],
581-
kwargs=kwargs,
586+
kwargs=task_kwargs,
582587
)
583588
return OperationPostponedResponse(result, request)
584589

@@ -770,7 +775,7 @@ class FileAlternateContentSourceViewSet(AlternateContentSourceViewSet, RolesMixi
770775
responses={202: TaskGroupOperationResponseSerializer},
771776
)
772777
@action(methods=["post"], detail=True)
773-
def refresh(self, request, pk):
778+
def refresh(self, request, pk, **kwargs):
774779
"""
775780
Refresh ACS metadata.
776781
"""
@@ -794,18 +799,18 @@ def refresh(self, request, pk):
794799
acs_url = (
795800
os.path.join(acs.remote.url, acs_path.path) if acs_path.path else acs.remote.url
796801
)
797-
802+
task_kwargs = {
803+
"remote_pk": str(acs.remote.pk),
804+
"repository_pk": str(acs_path.repository.pk),
805+
"mirror": False,
806+
"url": acs_url,
807+
}
798808
# Dispatching ACS path to own task and assign it to common TaskGroup
799809
dispatch(
800810
tasks.synchronize,
801811
shared_resources=[acs.remote, acs],
802812
task_group=task_group,
803-
kwargs={
804-
"remote_pk": str(acs.remote.pk),
805-
"repository_pk": str(acs_path.repository.pk),
806-
"mirror": False,
807-
"url": acs_url,
808-
},
813+
kwargs=task_kwargs,
809814
)
810815

811816
return TaskGroupOperationResponse(task_group, request)

pulpcore/app/contexts.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
from contextvars import ContextVar
33

44
from asgiref.sync import sync_to_async
5+
from django.conf import settings
56
from django_guid import clear_guid, get_guid, set_guid
67

78
_current_task = ContextVar("current_task", default=None)
89
_current_user_func = ContextVar("current_user", default=lambda: None)
910
_current_domain = ContextVar("current_domain", default=None)
1011
x_task_diagnostics_var = ContextVar("x_profile_task")
12+
current_pulp_api_version = ContextVar(
13+
"current_pulp_api_version", default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
14+
)
1115

1216

1317
@contextmanager
@@ -45,6 +49,13 @@ def with_domain(domain):
4549
def with_task_context(task):
4650
with with_domain(task.pulp_domain), with_guid(task.logging_cid), with_user(task.user):
4751
task_token = _current_task.set(task)
52+
if not task.pulp_api_version:
53+
vers_token = current_pulp_api_version.set(
54+
settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
55+
)
56+
else:
57+
vers_token = current_pulp_api_version.set(task.pulp_api_version)
58+
4859
# If this task is being spawned by another task, we should inherit the profile options
4960
# from the current task.
5061
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
@@ -53,6 +64,7 @@ def with_task_context(task):
5364
finally:
5465
x_task_diagnostics_var.reset(diagnostics_token)
5566
_current_task.reset(task_token)
67+
current_pulp_api_version.reset(vers_token)
5668

5769

5870
@asynccontextmanager
@@ -64,6 +76,12 @@ def _fetch(task):
6476
domain, user = await _fetch(task)
6577
with with_domain(domain), with_guid(task.logging_cid), with_user(user):
6678
task_token = _current_task.set(task)
79+
if not task.pulp_api_version:
80+
vers_token = current_pulp_api_version.set(
81+
settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
82+
)
83+
else:
84+
vers_token = current_pulp_api_version.set(task.pulp_api_version)
6785
# If this task is being spawned by another task, we should inherit the profile options
6886
# from the current task.
6987
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
@@ -72,3 +90,4 @@ def _fetch(task):
7290
finally:
7391
x_task_diagnostics_var.reset(diagnostics_token)
7492
_current_task.reset(task_token)
93+
current_pulp_api_version.reset(vers_token)

pulpcore/app/find_url.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ def find_api_root(version="v3", set_domain=True, domain=None, lstrip=False, rewr
3838
# Some current path-building wants to ignore DOMAIN - make that possible
3939
if set_domain and settings.DOMAIN_ENABLED:
4040
if domain:
41-
path = f"{api_root}{domain}/api/{version}/"
41+
path = rf"{api_root}{domain}/api/{version}/"
4242
else:
43-
path = f"{api_root}{DOMAIN_SLUG}/api/{version}/"
43+
path = rf"{api_root}{DOMAIN_SLUG}/api/{version}/"
4444
else:
45-
path = f"{api_root}api/{version}/"
45+
path = rf"{api_root}api/{version}/"
4646
if lstrip:
4747
return api_root.lstrip("/"), path.lstrip("/")
4848
else:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.14 on 2026-07-10 23:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0153_taskschedule_pulp_domain_alter_taskschedule_name_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='task',
15+
name='pulp_api_version',
16+
field=models.TextField(default='v3'),
17+
),
18+
]

pulpcore/app/models/task.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import traceback
77
from gettext import gettext as _
88

9+
from django.conf import settings
910
from django.contrib.postgres.fields import ArrayField, HStoreField
1011
from django.contrib.postgres.indexes import GinIndex
1112
from django.core.serializers.json import DjangoJSONEncoder
@@ -143,6 +144,10 @@ class Task(BaseModel, AutoAddObjPermsMixin):
143144

144145
result = models.JSONField(default=None, null=True, encoder=DjangoJSONEncoder)
145146

147+
pulp_api_version = models.TextField(
148+
default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
149+
)
150+
146151
@property
147152
def user(self):
148153
# These queries were specifically constructed and ordered this way to ensure we have the

pulpcore/app/serializers/status.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,12 @@ class StatusSerializer(serializers.Serializer):
136136
content_settings = ContentSettingsSerializer(help_text=_("Content-app settings"))
137137

138138
domain_enabled = serializers.BooleanField(help_text=_("Is Domains enabled"))
139+
140+
141+
class V4StatusSerializer(StatusSerializer):
142+
pulp_api_version = serializers.CharField(
143+
help_text=_("Pulp API-Version called to generate this status"), default="not-set"
144+
)
145+
supported_pulp_api_versions = serializers.ListField(
146+
help_text=_("Pulp API-Versions currently enabled in this Pulp instance")
147+
)

pulpcore/app/serializers/task.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import typing as t
22
from gettext import gettext as _
33

4+
from django.conf import settings
45
from drf_spectacular.types import OpenApiTypes
56
from drf_spectacular.utils import extend_schema_serializer
67
from rest_framework import serializers
@@ -118,6 +119,11 @@ class TaskSerializer(ModelSerializer):
118119
help_text=_("The result of this task."),
119120
)
120121

122+
pulp_api_version = serializers.CharField(
123+
help_text=_("The API-version that was invoked when creating the task."),
124+
default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"),
125+
)
126+
121127
def get_worker(self, obj) -> t.Optional[OpenApiTypes.URI]:
122128
return None
123129

@@ -149,13 +155,15 @@ class Meta:
149155
"created_resource_prns",
150156
"reserved_resources_record",
151157
"result",
158+
"pulp_api_version",
152159
)
153160

154161

155162
class MinimalTaskSerializer(TaskSerializer):
156163
class Meta:
157164
model = models.Task
158165
fields = ModelSerializer.Meta.fields + (
166+
"pulp_api_version",
159167
"name",
160168
"state",
161169
"unblocked_at",

0 commit comments

Comments
 (0)