Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGES/6462.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Add `/v4/` API to Pulp.

This adds a `/v4/` API path to Pulp, in parallel to the existing `/v3/` path. The two
are currently (nearly) identical APIs - see the `/pulp/api/v4/status/` ouput for the
only (current) end-user-visible impact.

This change is primarily setting the stage to allow for future API changes and growth.
It is in TECH PREVIEW, and is likely to have significant changes happening to it as we
continue integrating into the rest of the Pulp architecture.
2 changes: 1 addition & 1 deletion pulp_file/app/tasks/publishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
log = logging.getLogger(__name__)


def publish(manifest, repository_version_pk, checkpoint=False):
def publish(manifest, repository_version_pk, checkpoint=False, **kwargs):
"""
Create a Publication based on a RepositoryVersion.
Expand Down
1 change: 1 addition & 0 deletions pulp_file/app/tasks/synchronizing.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def synchronize(remote_pk, repository_pk, mirror, optimize=False, url=None, **kw
ValueError: If the remote does not specify a URL to sync.
"""

remote = Remote.objects.get(pk=remote_pk).cast()
repository = FileRepository.objects.get(pk=repository_pk)

Expand Down
52 changes: 32 additions & 20 deletions pulp_file/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class FileContentViewSet(SingleArtifactContentUploadViewSet):
summary="Upload a File synchronously.",
)
@action(detail=False, methods=["post"], serializer_class=FileContentUploadSerializer)
def upload(self, request):
def upload(self, request, **kwargs):
"""Create a File."""
serializer = self.get_serializer(data=request.data)
with transaction.atomic():
Expand Down Expand Up @@ -258,7 +258,7 @@ class FileRepositoryViewSet(RepositoryViewSet, ModifyRepositoryActionMixin, Role
responses={202: AsyncOperationResponseSerializer},
)
@action(detail=True, methods=["post"], serializer_class=FileRepositorySyncURLSerializer)
def sync(self, request, pk):
def sync(self, request, pk, **kwargs):

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.

Apart from version, what arguments can end up being passed here? And what is the likelihood that any would overlap with task arguments?

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.

Especially provided this applies across basically all tasks, so there are a wide variety of inputs that could potentially overlap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To address this, changed to be specific about version - and also responded to @mdellweg 's "can this be api_version" comment, because that will also reduce collision-chance.

"""
Synchronizes a repository.

Expand All @@ -276,16 +276,21 @@ def sync(self, request, pk):
optimize = serializer.validated_data.get("optimize", True) # noqa
if mirror and repository.autopublish:
raise ValidationError("Cannot use mirror mode with autopublished repository.")

task_kwargs = {
"remote_pk": str(remote.pk),
"repository_pk": str(repository.pk),
"mirror": mirror,
"optimize": optimize,
}
if kwargs.get("version"):
task_kwargs["api_version"] = kwargs.get("version")

result = dispatch(
tasks.synchronize,
shared_resources=[remote],
exclusive_resources=[repository],
kwargs={
"remote_pk": str(remote.pk),
"repository_pk": str(repository.pk),
"mirror": mirror,
"optimize": optimize,
},
kwargs=task_kwargs,
)
return OperationPostponedResponse(result, request)

Expand Down Expand Up @@ -559,7 +564,7 @@ class FilePublicationViewSet(PublicationViewSet, RolesMixin):
description="Trigger an asynchronous task to publish file content.",
responses={202: AsyncOperationResponseSerializer},
)
def create(self, request):
def create(self, request, **kwargs):
"""
Publishes a repository.

Expand All @@ -572,13 +577,18 @@ def create(self, request):
manifest = serializer.validated_data.get("manifest")
checkpoint = serializer.validated_data.get("checkpoint")

kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}
task_kwargs = {"repository_version_pk": str(repository_version.pk), "manifest": manifest}

if checkpoint:
kwargs["checkpoint"] = True
task_kwargs["checkpoint"] = True

if kwargs.get("version"):
task_kwargs["api_version"] = kwargs.get("version")

result = dispatch(
tasks.publish,
shared_resources=[repository_version.repository],
kwargs=kwargs,
kwargs=task_kwargs,
)
return OperationPostponedResponse(result, request)

Expand Down Expand Up @@ -770,7 +780,7 @@ class FileAlternateContentSourceViewSet(AlternateContentSourceViewSet, RolesMixi
responses={202: TaskGroupOperationResponseSerializer},
)
@action(methods=["post"], detail=True)
def refresh(self, request, pk):
def refresh(self, request, pk, **kwargs):
"""
Refresh ACS metadata.
"""
Expand All @@ -794,18 +804,20 @@ def refresh(self, request, pk):
acs_url = (
os.path.join(acs.remote.url, acs_path.path) if acs_path.path else acs.remote.url
)

task_kwargs = {
"remote_pk": str(acs.remote.pk),
"repository_pk": str(acs_path.repository.pk),
"mirror": False,
"url": acs_url,
}
if kwargs.get("version"):
task_kwargs["api_version"] = kwargs.get("version")
# Dispatching ACS path to own task and assign it to common TaskGroup
dispatch(
tasks.synchronize,
shared_resources=[acs.remote, acs],
task_group=task_group,
kwargs={
"remote_pk": str(acs.remote.pk),
"repository_pk": str(acs_path.repository.pk),
"mirror": False,
"url": acs_url,
},
kwargs=task_kwargs,
)

return TaskGroupOperationResponse(task_group, request)
19 changes: 19 additions & 0 deletions pulpcore/app/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
from contextvars import ContextVar

from asgiref.sync import sync_to_async
from django.conf import settings
from django_guid import clear_guid, get_guid, set_guid

_current_task = ContextVar("current_task", default=None)
_current_user_func = ContextVar("current_user", default=lambda: None)
_current_domain = ContextVar("current_domain", default=None)
x_task_diagnostics_var = ContextVar("x_profile_task")
_current_pulp_version = ContextVar(

@ggainey ggainey Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: Is this really how we want to "know" what pulp-version we are operating under? Author, please review!

(also - even if we keep this, name needs to be _current_pulp_api_version)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That I kind ow liked. It's non-invasive, yet omnipresent inside the task code.

"current_pulp_version", default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
)


@contextmanager
Expand Down Expand Up @@ -45,6 +49,13 @@ def with_domain(domain):
def with_task_context(task):
with with_domain(task.pulp_domain), with_guid(task.logging_cid), with_user(task.user):
task_token = _current_task.set(task)
if not task.api_version:
vers_token = _current_pulp_version.set(
settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
)
else:
vers_token = _current_pulp_version.set(task.api_version)

# If this task is being spawned by another task, we should inherit the profile options
# from the current task.
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
Expand All @@ -53,6 +64,7 @@ def with_task_context(task):
finally:
x_task_diagnostics_var.reset(diagnostics_token)
_current_task.reset(task_token)
_current_pulp_version.reset(vers_token)


@asynccontextmanager
Expand All @@ -64,6 +76,12 @@ def _fetch(task):
domain, user = await _fetch(task)
with with_domain(domain), with_guid(task.logging_cid), with_user(user):
task_token = _current_task.set(task)
if not task.api_version:
vers_token = _current_pulp_version.set(
settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
)
else:
vers_token = _current_pulp_version.set(task.api_version)
# If this task is being spawned by another task, we should inherit the profile options
# from the current task.
diagnostics_token = x_task_diagnostics_var.set(task.profile_options)
Expand All @@ -72,3 +90,4 @@ def _fetch(task):
finally:
x_task_diagnostics_var.reset(diagnostics_token)
_current_task.reset(task_token)
_current_pulp_version.reset(vers_token)
6 changes: 3 additions & 3 deletions pulpcore/app/find_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ def find_api_root(version="v3", set_domain=True, domain=None, lstrip=False, rewr
# Some current path-building wants to ignore DOMAIN - make that possible
if set_domain and settings.DOMAIN_ENABLED:
if domain:
path = f"{api_root}{domain}/api/{version}/"
path = rf"{api_root}{domain}/api/{version}/"
else:
path = f"{api_root}{DOMAIN_SLUG}/api/{version}/"
path = rf"{api_root}{DOMAIN_SLUG}/api/{version}/"
else:
path = f"{api_root}api/{version}/"
path = rf"{api_root}api/{version}/"
if lstrip:
return api_root.lstrip("/"), path.lstrip("/")
else:
Expand Down
18 changes: 18 additions & 0 deletions pulpcore/app/migrations/0154_task_api_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-07-10 23:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0153_taskschedule_pulp_domain_alter_taskschedule_name_and_more'),
]

operations = [
migrations.AddField(
model_name='task',
name='api_version',
field=models.TextField(default='v3'),
),
]
3 changes: 3 additions & 0 deletions pulpcore/app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import traceback
from gettext import gettext as _

from django.conf import settings
from django.contrib.postgres.fields import ArrayField, HStoreField
from django.contrib.postgres.indexes import GinIndex
from django.core.serializers.json import DjangoJSONEncoder
Expand Down Expand Up @@ -143,6 +144,8 @@ class Task(BaseModel, AutoAddObjPermsMixin):

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

api_version = models.TextField(default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))

@property
def user(self):
# These queries were specifically constructed and ordered this way to ensure we have the
Expand Down
9 changes: 9 additions & 0 deletions pulpcore/app/serializers/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,12 @@ class StatusSerializer(serializers.Serializer):
content_settings = ContentSettingsSerializer(help_text=_("Content-app settings"))

domain_enabled = serializers.BooleanField(help_text=_("Is Domains enabled"))


class V4StatusSerializer(StatusSerializer):
api_version = serializers.CharField(
help_text=_("API-Version called to generate this status"), default="not-set"
)
supported_api_versions = serializers.ListField(
help_text=_("API-Versions currently enabled in this Pulp instance")
)
8 changes: 8 additions & 0 deletions pulpcore/app/serializers/task.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import typing as t
from gettext import gettext as _

from django.conf import settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
Expand Down Expand Up @@ -118,6 +119,11 @@ class TaskSerializer(ModelSerializer):
help_text=_("The result of this task."),
)

api_version = serializers.CharField(
help_text=_("The API-version that was invoked when creating the task."),
default=settings.REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"),
)

def get_worker(self, obj) -> t.Optional[OpenApiTypes.URI]:
return None

Expand Down Expand Up @@ -149,13 +155,15 @@ class Meta:
"created_resource_prns",
"reserved_resources_record",
"result",
"api_version",
)


class MinimalTaskSerializer(TaskSerializer):
class Meta:
model = models.Task
fields = ModelSerializer.Meta.fields + (
"api_version",
"name",
"state",
"unblocked_at",
Expand Down
22 changes: 17 additions & 5 deletions pulpcore/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@
},
]

ENABLE_V4_API = True
WSGI_APPLICATION = "pulpcore.app.wsgi.application"

REST_FRAMEWORK = {
"URL_FIELD_NAME": "pulp_href",
"DEFAULT_FILTER_BACKENDS": ("pulpcore.filters.PulpFilterBackend",),
Expand Down Expand Up @@ -619,6 +619,14 @@ def saml2_settings_hook(settings):
return data


def enable_v4_hook(settings):
data = {"dynaconf_merge": True}
if settings.ENABLE_V4_API:
data["REST_FRAMEWORK.ALLOWED_VERSIONS"] = ["v3", "v4"]
data["REST_FRAMEWORK.DEFAULT_VERSION"] = "v3"
return data


del preload_settings

settings = DjangoDynaconf(
Expand All @@ -643,7 +651,11 @@ def saml2_settings_hook(settings):
otel_metrics_dispatch_interval_validator,
distributed_publication_retention_period_validator,
],
post_hooks=(otel_middleware_hook, saml2_settings_hook),
post_hooks=(
otel_middleware_hook,
saml2_settings_hook,
enable_v4_hook,
),
dynaboxify=False,
)

Expand All @@ -669,13 +681,13 @@ def saml2_settings_hook(settings):
ALLOWED_CONTENT_CHECKSUMS
)

# protocol://host:port/{API_ROOT}{domain}/api/{version}/
# All of the below are DEPRECATED, and should be replaced by calling
# pulpcore.plugin.find_url.find_api_root() (q.v.)
if settings.API_ROOT_REWRITE_HEADER:
api_root = "/<path:api_root>/"
else:
api_root = settings.API_ROOT
# protocol://host:port/{API_ROOT}{domain}/api/{version}/
# All of the below are DEPRECATED, and should be replaced by calling
# pulpcore.plugin.find_url.find_api_root() (q.v.)
settings.set("V3_API_ROOT", api_root + "api/v3/") # Not user configurable
settings.set("V3_DOMAIN_API_ROOT", api_root + "<slug:pulp_domain>/api/v3/")
settings.set("V3_API_ROOT_NO_FRONT_SLASH", settings.V3_API_ROOT.lstrip("/"))
Expand Down
6 changes: 3 additions & 3 deletions pulpcore/app/tasks/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def general_update(instance_id, app_label, serializer_name, *args, **kwargs):
serializer.save()


def general_delete(instance_id, app_label, serializer_name):
def general_delete(instance_id, app_label, serializer_name, **kwargs):
"""
Delete a model

Expand All @@ -105,7 +105,7 @@ def general_delete(instance_id, app_label, serializer_name):
instance.delete()


def general_multi_delete(instance_ids):
def general_multi_delete(instance_ids, **kwargs):
"""
Delete a list of model instances in a transaction

Expand Down Expand Up @@ -145,7 +145,7 @@ async def ageneral_update(instance_id, app_label, serializer_name, *args, **kwar
return await sync_to_async(lambda: serializer.data)()


async def ageneral_delete(instance_id, app_label, serializer_name):
async def ageneral_delete(instance_id, app_label, serializer_name, **kwargs):
"""
Async version of [pulpcore.app.tasks.base.general_delete][].
"""
Expand Down
2 changes: 1 addition & 1 deletion pulpcore/app/tasks/datarepair.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
log = getLogger(__name__)


def repair_7272(dry_run=False):
def repair_7272(dry_run=False, **kwargs):
"""
Repair repository version content_ids cache and content count mismatches (Issue #7272).
Expand Down
Loading
Loading