Skip to content

Commit 75e4f88

Browse files
committed
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
1 parent 243cf47 commit 75e4f88

45 files changed

Lines changed: 463 additions & 221 deletions

Some content is hidden

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

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: 27 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,20 @@ 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-
result = dispatch(
280-
tasks.synchronize,
281-
shared_resources=[remote],
282-
exclusive_resources=[repository],
283-
kwargs={
279+
280+
task_kwargs = {
284281
"remote_pk": str(remote.pk),
285282
"repository_pk": str(repository.pk),
286283
"mirror": mirror,
287284
"optimize": optimize,
288-
},
285+
}
286+
task_kwargs.update(kwargs)
287+
288+
result = dispatch(
289+
tasks.synchronize,
290+
shared_resources=[remote],
291+
exclusive_resources=[repository],
292+
kwargs=task_kwargs,
289293
)
290294
return OperationPostponedResponse(result, request)
291295

@@ -559,7 +563,7 @@ class FilePublicationViewSet(PublicationViewSet, RolesMixin):
559563
description="Trigger an asynchronous task to publish file content.",
560564
responses={202: AsyncOperationResponseSerializer},
561565
)
562-
def create(self, request):
566+
def create(self, request, **kwargs):
563567
"""
564568
Publishes a repository.
565569
@@ -572,13 +576,15 @@ def create(self, request):
572576
manifest = serializer.validated_data.get("manifest")
573577
checkpoint = serializer.validated_data.get("checkpoint")
574578

575-
kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}
579+
task_kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}
580+
task_kwargs.update(kwargs)
581+
576582
if checkpoint:
577-
kwargs["checkpoint"] = True
583+
task_kwargs["checkpoint"] = True
578584
result = dispatch(
579585
tasks.publish,
580586
shared_resources=[repository_version.repository],
581-
kwargs=kwargs,
587+
kwargs=task_kwargs,
582588
)
583589
return OperationPostponedResponse(result, request)
584590

@@ -770,7 +776,7 @@ class FileAlternateContentSourceViewSet(AlternateContentSourceViewSet, RolesMixi
770776
responses={202: TaskGroupOperationResponseSerializer},
771777
)
772778
@action(methods=["post"], detail=True)
773-
def refresh(self, request, pk):
779+
def refresh(self, request, pk, **kwargs):
774780
"""
775781
Refresh ACS metadata.
776782
"""
@@ -794,18 +800,19 @@ def refresh(self, request, pk):
794800
acs_url = (
795801
os.path.join(acs.remote.url, acs_path.path) if acs_path.path else acs.remote.url
796802
)
797-
803+
task_kwargs = {
804+
"remote_pk": str(acs.remote.pk),
805+
"repository_pk": str(acs_path.repository.pk),
806+
"mirror": False,
807+
"url": acs_url,
808+
}
809+
task_kwargs.update(kwargs)
798810
# Dispatching ACS path to own task and assign it to common TaskGroup
799811
dispatch(
800812
tasks.synchronize,
801813
shared_resources=[acs.remote, acs],
802814
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-
},
815+
kwargs=task_kwargs,
809816
)
810817

811818
return TaskGroupOperationResponse(task_group, request)

pulpcore/app/contexts.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
from asgiref.sync import sync_to_async
55
from django_guid import clear_guid, get_guid, set_guid
66

7+
from pulpcore.app.settings import REST_FRAMEWORK
8+
79
_current_task = ContextVar("current_task", default=None)
810
_current_user_func = ContextVar("current_user", default=lambda: None)
911
_current_domain = ContextVar("current_domain", default=None)
1012
x_task_diagnostics_var = ContextVar("x_profile_task")
13+
_current_pulp_version = ContextVar(
14+
"current_pulp_version", default=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
15+
)
1116

1217

1318
@contextmanager
@@ -45,6 +50,11 @@ def with_domain(domain):
4550
def with_task_context(task):
4651
with with_domain(task.pulp_domain), with_guid(task.logging_cid), with_user(task.user):
4752
task_token = _current_task.set(task)
53+
if not task.version:
54+
vers_token = _current_pulp_version.set(REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))
55+
else:
56+
vers_token = _current_pulp_version.set(task.version)
57+
4858
# If this task is being spawned by another task, we should inherit the profile options
4959
# from the current task.
5060
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
@@ -53,6 +63,7 @@ def with_task_context(task):
5363
finally:
5464
x_task_diagnostics_var.reset(diagnostics_token)
5565
_current_task.reset(task_token)
66+
_current_pulp_version.reset(vers_token)
5667

5768

5869
@asynccontextmanager
@@ -64,6 +75,10 @@ def _fetch(task):
6475
domain, user = await _fetch(task)
6576
with with_domain(domain), with_guid(task.logging_cid), with_user(user):
6677
task_token = _current_task.set(task)
78+
if not task.version:
79+
vers_token = _current_pulp_version.set(REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))
80+
else:
81+
vers_token = _current_pulp_version.set(task.version)
6782
# If this task is being spawned by another task, we should inherit the profile options
6883
# from the current task.
6984
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
@@ -72,3 +87,4 @@ def _fetch(task):
7287
finally:
7388
x_task_diagnostics_var.reset(diagnostics_token)
7489
_current_task.reset(task_token)
90+
_current_pulp_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-06-01 23:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0151_upstreampulp_connect_timeout_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='task',
15+
name='version',
16+
field=models.TextField(default='v3'),
17+
),
18+
]

pulpcore/app/models/task.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pulpcore.app.models.fields import EncryptedJSONField
2424
from pulpcore.app.models.status import AppStatus
2525
from pulpcore.app.role_util import get_users_with_perms
26+
from pulpcore.app.settings import REST_FRAMEWORK
2627
from pulpcore.app.util import get_domain_pk
2728
from pulpcore.constants import TASK_CHOICES, TASK_INCOMPLETE_STATES, TASK_STATES
2829
from pulpcore.exceptions import exception_to_dict
@@ -143,6 +144,8 @@ class Task(BaseModel, AutoAddObjPermsMixin):
143144

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

147+
version = models.TextField(default=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))
148+
146149
@property
147150
def user(self):
148151
# 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+
api_version = serializers.CharField(
143+
help_text=_("API-Version called to generate this status"), default="not-set"
144+
)
145+
supported_api_versions = serializers.ListField(
146+
help_text=_("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
@@ -16,6 +16,7 @@
1616
TaskGroupStatusCountField,
1717
fields,
1818
)
19+
from pulpcore.app.settings import REST_FRAMEWORK
1920
from pulpcore.app.util import get_prn, reverse
2021
from pulpcore.constants import TASK_STATES
2122

@@ -118,6 +119,11 @@ class TaskSerializer(ModelSerializer):
118119
help_text=_("The result of this task."),
119120
)
120121

122+
version = serializers.CharField(
123+
help_text=_("The API-version that was invoked when creating the task."),
124+
default=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+
"version",
152159
)
153160

154161

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

pulpcore/app/settings.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
],
4848
)
4949

50+
ENABLE_V4_API = False
51+
5052
# Build paths inside the project like this: BASE_DIR / ...
5153
BASE_DIR = Path(__file__).absolute().parent
5254

@@ -179,7 +181,6 @@
179181
]
180182

181183
WSGI_APPLICATION = "pulpcore.app.wsgi.application"
182-
183184
REST_FRAMEWORK = {
184185
"URL_FIELD_NAME": "pulp_href",
185186
"DEFAULT_FILTER_BACKENDS": ("pulpcore.filters.PulpFilterBackend",),
@@ -194,6 +195,11 @@
194195
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
195196
"DEFAULT_SCHEMA_CLASS": "pulpcore.openapi.PulpAutoSchema",
196197
}
198+
ENABLE_V4_API = True
199+
if ENABLE_V4_API:
200+
REST_FRAMEWORK["ALLOWED_VERSIONS"] = ["v3", "v4"]
201+
REST_FRAMEWORK["DEFAULT_VERSION"] = "v3"
202+
197203

198204
# Password validation
199205
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
@@ -653,13 +659,13 @@ def otel_middleware_hook(settings):
653659
ALLOWED_CONTENT_CHECKSUMS
654660
)
655661

662+
# protocol://host:port/{API_ROOT}{domain}/api/{version}/
663+
# All of the below are DEPRECATED, and should be replaced by calling
664+
# pulpcore.plugin.find_url.find_api_root() (q.v.)
656665
if settings.API_ROOT_REWRITE_HEADER:
657666
api_root = "/<path:api_root>/"
658667
else:
659668
api_root = settings.API_ROOT
660-
# protocol://host:port/{API_ROOT}{domain}/api/{version}/
661-
# All of the below are DEPRECATED, and should be replaced by calling
662-
# pulpcore.plugin.find_url.find_api_root() (q.v.)
663669
settings.set("V3_API_ROOT", api_root + "api/v3/") # Not user configurable
664670
settings.set("V3_DOMAIN_API_ROOT", api_root + "<slug:pulp_domain>/api/v3/")
665671
settings.set("V3_API_ROOT_NO_FRONT_SLASH", settings.V3_API_ROOT.lstrip("/"))

0 commit comments

Comments
 (0)