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
47 changes: 27 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):
"""
Synchronizes a repository.

Expand All @@ -276,16 +276,20 @@ 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,
}
task_kwargs.update(kwargs)

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 +563,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 +576,15 @@ 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}
task_kwargs.update(kwargs)

if checkpoint:
kwargs["checkpoint"] = True
task_kwargs["checkpoint"] = True
result = dispatch(
tasks.publish,
shared_resources=[repository_version.repository],
kwargs=kwargs,
kwargs=task_kwargs,
)
return OperationPostponedResponse(result, request)

Expand Down Expand Up @@ -770,7 +776,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 +800,19 @@ 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,
}
task_kwargs.update(kwargs)
# 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)
16 changes: 16 additions & 0 deletions pulpcore/app/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
from asgiref.sync import sync_to_async
from django_guid import clear_guid, get_guid, set_guid

from pulpcore.app.settings import REST_FRAMEWORK

_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=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")
)


@contextmanager
Expand Down Expand Up @@ -45,6 +50,11 @@ 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.version:
vers_token = _current_pulp_version.set(REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))
else:
vers_token = _current_pulp_version.set(task.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 +63,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 +75,10 @@ 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.version:
vers_token = _current_pulp_version.set(REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"))
else:
vers_token = _current_pulp_version.set(task.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 +87,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/0153_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-06-01 23:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0152_alter_repositoryversion_content_ids'),
]

operations = [
migrations.AddField(
model_name='task',
name='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 @@ -23,6 +23,7 @@
from pulpcore.app.models.fields import EncryptedJSONField
from pulpcore.app.models.status import AppStatus
from pulpcore.app.role_util import get_users_with_perms
from pulpcore.app.settings import REST_FRAMEWORK
from pulpcore.app.util import get_domain_pk
from pulpcore.constants import TASK_CHOICES, TASK_INCOMPLETE_STATES, TASK_STATES
from pulpcore.exceptions import exception_to_dict
Expand Down Expand Up @@ -143,6 +144,8 @@ class Task(BaseModel, AutoAddObjPermsMixin):

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

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

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.

Can we call this api_version?

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.

"version" as an attribute is "magic" at the DRF level - it's what gets filled in by the content of the <str:version> slug in the urlpattern. I debated on exactly this - but making it "version" means it's filled directly by the incoming **kwargs to the view, naming it anything else means calling it out specifically. I could be convinced of either.

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.

I was just abit afraid that we could be confusing here. And changing a database column name later is a pain.

(Pulp is in the domain for versioning on all levels. Pulp-version, pulp-api-version, repository-version, deb-version, rpm-version, ansible-collection-version, ...)


@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
Expand Up @@ -16,6 +16,7 @@
TaskGroupStatusCountField,
fields,
)
from pulpcore.app.settings import REST_FRAMEWORK
from pulpcore.app.util import get_prn, reverse
from pulpcore.constants import TASK_STATES

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

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

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.

Suggested change
default=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3"),

I don't think we need this, tasks will never be created via this serializer.

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.

Possibly so, will dig a bit.

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.

I don't think we need this, tasks will never be created via this serializer.

You would think, but I remember there being some surprising ways and places in which the serializers were getting initialized.

)

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",
"version",
)


class MinimalTaskSerializer(TaskSerializer):
class Meta:
model = models.Task
fields = ModelSerializer.Meta.fields + (
"version",
"name",
"state",
"unblocked_at",
Expand Down
21 changes: 16 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 @@ -604,6 +604,14 @@ def otel_middleware_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 @@ -628,7 +636,10 @@ def otel_middleware_hook(settings):
otel_metrics_dispatch_interval_validator,
distributed_publication_retention_period_validator,
],
post_hooks=(otel_middleware_hook,),
post_hooks=(
otel_middleware_hook,
enable_v4_hook,
),
)

_logger = getLogger(__name__)
Expand All @@ -653,13 +664,13 @@ def otel_middleware_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