diff --git a/CHANGES/6462.feature b/CHANGES/6462.feature new file mode 100644 index 00000000000..79000af89e0 --- /dev/null +++ b/CHANGES/6462.feature @@ -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. diff --git a/pulp_file/app/tasks/publishing.py b/pulp_file/app/tasks/publishing.py index b3a3830bf56..6d8f9e41966 100644 --- a/pulp_file/app/tasks/publishing.py +++ b/pulp_file/app/tasks/publishing.py @@ -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. diff --git a/pulp_file/app/tasks/synchronizing.py b/pulp_file/app/tasks/synchronizing.py index 22eb7558bc3..f9f9a54e8b5 100644 --- a/pulp_file/app/tasks/synchronizing.py +++ b/pulp_file/app/tasks/synchronizing.py @@ -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) diff --git a/pulp_file/app/viewsets.py b/pulp_file/app/viewsets.py index 67b3f44ff30..75c73618da6 100644 --- a/pulp_file/app/viewsets.py +++ b/pulp_file/app/viewsets.py @@ -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(): @@ -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. @@ -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) @@ -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. @@ -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) @@ -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. """ @@ -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) diff --git a/pulpcore/app/contexts.py b/pulpcore/app/contexts.py index 9e2d6fd1657..97b9a778137 100644 --- a/pulpcore/app/contexts.py +++ b/pulpcore/app/contexts.py @@ -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( + "current_pulp_version", default=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3") +) @contextmanager @@ -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) @@ -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 @@ -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) @@ -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) diff --git a/pulpcore/app/find_url.py b/pulpcore/app/find_url.py index 70f34798ff4..1fcfa1127c7 100644 --- a/pulpcore/app/find_url.py +++ b/pulpcore/app/find_url.py @@ -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: diff --git a/pulpcore/app/migrations/0153_task_api_version.py b/pulpcore/app/migrations/0153_task_api_version.py new file mode 100644 index 00000000000..cf362e0a455 --- /dev/null +++ b/pulpcore/app/migrations/0153_task_api_version.py @@ -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'), + ), + ] diff --git a/pulpcore/app/models/task.py b/pulpcore/app/models/task.py index a24d1870163..9921c9cd1ce 100644 --- a/pulpcore/app/models/task.py +++ b/pulpcore/app/models/task.py @@ -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 @@ -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")) + @property def user(self): # These queries were specifically constructed and ordered this way to ensure we have the diff --git a/pulpcore/app/serializers/status.py b/pulpcore/app/serializers/status.py index 8adb8458973..49dab361b0a 100644 --- a/pulpcore/app/serializers/status.py +++ b/pulpcore/app/serializers/status.py @@ -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") + ) diff --git a/pulpcore/app/serializers/task.py b/pulpcore/app/serializers/task.py index 8d79bd042a1..1f1b233c508 100644 --- a/pulpcore/app/serializers/task.py +++ b/pulpcore/app/serializers/task.py @@ -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 @@ -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"), + ) + def get_worker(self, obj) -> t.Optional[OpenApiTypes.URI]: return None @@ -149,6 +155,7 @@ class Meta: "created_resource_prns", "reserved_resources_record", "result", + "version", ) @@ -156,6 +163,7 @@ class MinimalTaskSerializer(TaskSerializer): class Meta: model = models.Task fields = ModelSerializer.Meta.fields + ( + "version", "name", "state", "unblocked_at", diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py index e79a2f26ad4..68ba2107f0e 100644 --- a/pulpcore/app/settings.py +++ b/pulpcore/app/settings.py @@ -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",), @@ -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( @@ -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__) @@ -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 = "//" 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 + "/api/v3/") settings.set("V3_API_ROOT_NO_FRONT_SLASH", settings.V3_API_ROOT.lstrip("/")) diff --git a/pulpcore/app/tasks/base.py b/pulpcore/app/tasks/base.py index 23814fe5b06..1d3d2c6884f 100644 --- a/pulpcore/app/tasks/base.py +++ b/pulpcore/app/tasks/base.py @@ -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 @@ -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 @@ -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][]. """ diff --git a/pulpcore/app/tasks/datarepair.py b/pulpcore/app/tasks/datarepair.py index 21b3c9ec16c..abda496cacf 100644 --- a/pulpcore/app/tasks/datarepair.py +++ b/pulpcore/app/tasks/datarepair.py @@ -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). diff --git a/pulpcore/app/tasks/export.py b/pulpcore/app/tasks/export.py index f498e3161e6..669ddfff20d 100644 --- a/pulpcore/app/tasks/export.py +++ b/pulpcore/app/tasks/export.py @@ -291,7 +291,7 @@ def _export_location_is_clean(path): return True -def fs_publication_export(exporter_pk, publication_pk, start_repo_version_pk=None): +def fs_publication_export(exporter_pk, publication_pk, start_repo_version_pk=None, **kwargs): """ Export a publication to the file system using an exporter. @@ -335,7 +335,7 @@ def fs_publication_export(exporter_pk, publication_pk, start_repo_version_pk=Non ) -def fs_repo_version_export(exporter_pk, repo_version_pk, start_repo_version_pk=None): +def fs_repo_version_export(exporter_pk, repo_version_pk, start_repo_version_pk=None, **kwargs): """ Export a repository version to the file system using an exporter. @@ -474,7 +474,7 @@ def _incremental_requested(the_export): return (starting_versions_provided or last_exists) and not full -def pulp_export(exporter_pk, params): +def pulp_export(exporter_pk, params, **kwargs): """ Create a PulpExport to export pulp_exporter.repositories. diff --git a/pulpcore/app/tasks/importer.py b/pulpcore/app/tasks/importer.py index 337d41cbbc6..be452ab1bc2 100644 --- a/pulpcore/app/tasks/importer.py +++ b/pulpcore/app/tasks/importer.py @@ -321,7 +321,14 @@ def _check_versions(version_json): def import_repository_version( - importer_pk, src_repo_name, src_repo_type, dest_repo_name, dest_repo_pk, tar_path, toc_path=None + importer_pk, + src_repo_name, + src_repo_type, + dest_repo_name, + dest_repo_pk, + tar_path, + toc_path=None, + **kwargs, ): """ Import a repository version from a Pulp export. @@ -431,7 +438,7 @@ def import_repository_version( gpr.update(done=F("done") + 1) -def pulp_import(importer_pk, path, toc, create_repositories): +def pulp_import(importer_pk, path, toc, create_repositories, **kwargs): """ Import a Pulp export into Pulp. diff --git a/pulpcore/app/tasks/migrate.py b/pulpcore/app/tasks/migrate.py index dd15adc1ed8..09c9655aad5 100644 --- a/pulpcore/app/tasks/migrate.py +++ b/pulpcore/app/tasks/migrate.py @@ -11,7 +11,7 @@ _logger = logging.getLogger(__name__) -def migrate_backend(data): +def migrate_backend(data, **kwargs): """ Copy the artifacts from the current storage backend to a new one. Then update backend settings. diff --git a/pulpcore/app/tasks/orphan.py b/pulpcore/app/tasks/orphan.py index 42a37a572c0..c35068ee811 100644 --- a/pulpcore/app/tasks/orphan.py +++ b/pulpcore/app/tasks/orphan.py @@ -38,7 +38,9 @@ def queryset_iterator(qs, batchsize=2000, gc_collect=True): gc.collect() -def orphan_cleanup(content_pks=None, orphan_protection_time=settings.ORPHAN_PROTECTION_TIME): +def orphan_cleanup( + content_pks=None, orphan_protection_time=settings.ORPHAN_PROTECTION_TIME, **kwargs +): """ Delete all orphan Content and Artifact records. Go through orphan Content multiple times to remove content from subrepos. @@ -111,7 +113,7 @@ def orphan_cleanup(content_pks=None, orphan_protection_time=settings.ORPHAN_PROT log.info(msg.format(skipped_artifact)) -def upload_cleanup(): +def upload_cleanup(**kwargs): assert settings.UPLOAD_PROTECTION_TIME > 0 expiration = timezone.now() - timezone.timedelta(minutes=settings.UPLOAD_PROTECTION_TIME) qs = Upload.objects.filter(pulp_created__lt=expiration) @@ -124,7 +126,7 @@ def upload_cleanup(): upload.delete() -def tmpfile_cleanup(): +def tmpfile_cleanup(**kwargs): assert settings.TMPFILE_PROTECTION_TIME > 0 expiration = timezone.now() - timezone.timedelta(minutes=settings.TMPFILE_PROTECTION_TIME) qs = PulpTemporaryFile.objects.filter(pulp_created__lt=expiration) diff --git a/pulpcore/app/tasks/reclaim_space.py b/pulpcore/app/tasks/reclaim_space.py index be603e0728b..1c58ace6e33 100644 --- a/pulpcore/app/tasks/reclaim_space.py +++ b/pulpcore/app/tasks/reclaim_space.py @@ -16,7 +16,7 @@ log = getLogger(__name__) -def reclaim_space(repo_pks, keeplist_rv_pks=None, force=False): +def reclaim_space(repo_pks, keeplist_rv_pks=None, force=False, **kwargs): """ This task frees-up disk space by removing Artifact files from the filesystem for Content exclusive to the list of provided repos. diff --git a/pulpcore/app/tasks/replica.py b/pulpcore/app/tasks/replica.py index d5b9285f4ff..14a289ff745 100644 --- a/pulpcore/app/tasks/replica.py +++ b/pulpcore/app/tasks/replica.py @@ -28,7 +28,7 @@ def user_agent(): return f"pulpcore/{pulp_version} ({python}, {system}) (pulp-glue {pulp_glue_version})" -def replicate_distributions(server_pk, q_select=None): +def replicate_distributions(server_pk, q_select=None, **kwargs): server = UpstreamPulp.objects.get(pk=server_pk) # Write out temporary files related to SSL @@ -142,7 +142,7 @@ def replicate_distributions(server_pk, q_select=None): ) -def finalize_replication(server_pk, distro_repo_pairs): +def finalize_replication(server_pk, distro_repo_pairs, **kwargs): task = Task.current() task_group = TaskGroup.current() server = UpstreamPulp.objects.get(pk=server_pk) diff --git a/pulpcore/app/tasks/repository.py b/pulpcore/app/tasks/repository.py index 3a5f1c113e2..92afd6e3a9d 100644 --- a/pulpcore/app/tasks/repository.py +++ b/pulpcore/app/tasks/repository.py @@ -19,7 +19,7 @@ CHUNK_SIZE = 1024 * 1024 # 1 Mb -def delete_version(pk): +def delete_version(pk, **kwargs): """ Delete a repository version by squashing its changes with the next newer version. This ensures that the content set for each version stays the same. @@ -156,7 +156,7 @@ async def _repair_artifacts_for_content(subset=None, verify_checksums=True): await asyncio.gather(*pending) -def repair_version(repository_version_pk, verify_checksums): +def repair_version(repository_version_pk, verify_checksums, **kwargs): """ Repair the artifacts associated with this repository version. @@ -182,7 +182,7 @@ def repair_version(repository_version_pk, verify_checksums): ) -def repair_all_artifacts(verify_checksums): +def repair_all_artifacts(verify_checksums, **kwargs): """ Repair all artifacts, globally. @@ -199,7 +199,12 @@ def repair_all_artifacts(verify_checksums): def add_and_remove( - repository_pk, add_content_units, remove_content_units, base_version_pk=None, overwrite=True + repository_pk, + add_content_units, + remove_content_units, + base_version_pk=None, + overwrite=True, + **kwargs, ): """ Create a new repository version by adding and then removing content units. @@ -244,7 +249,7 @@ def add_and_remove( async def aadd_and_remove( - repository_pk, add_content_units, remove_content_units, base_version_pk=None + repository_pk, add_content_units, remove_content_units, base_version_pk=None, **kwargs ): """Aynsc version of add_and_remove.""" repository = await models.Repository.objects.aget(pk=repository_pk) diff --git a/pulpcore/app/tasks/upload.py b/pulpcore/app/tasks/upload.py index 8fb3eb6e463..85d2566661f 100644 --- a/pulpcore/app/tasks/upload.py +++ b/pulpcore/app/tasks/upload.py @@ -8,7 +8,7 @@ log = getLogger(__name__) -def commit(upload_id, sha256): +def commit(upload_id, sha256, **kwargs): """ Commit the upload and turn it into an artifact. diff --git a/pulpcore/app/urls.py b/pulpcore/app/urls.py index 2c2649ad82c..a4ce32ee3a4 100644 --- a/pulpcore/app/urls.py +++ b/pulpcore/app/urls.py @@ -1,7 +1,7 @@ """pulp URL Configuration""" from django.conf import settings -from django.urls import include, path +from django.urls import include, path, re_path from django.views.decorators.cache import cache_page from drf_spectacular.utils import extend_schema from drf_spectacular.views import ( @@ -14,6 +14,7 @@ from rest_framework_nested import routers from pulpcore.app.apps import pulp_plugin_configs +from pulpcore.app.settings import ENABLE_V4_API, REST_FRAMEWORK from pulpcore.app.views import ( DataRepair7272View, LivezView, @@ -30,11 +31,81 @@ ) from pulpcore.plugin.find_url import find_api_root -_, PATH_DOMAIN_REWRITE_NOFRONT = find_api_root(lstrip=True, set_domain=True, rewrite_header=True) -_, PATH_NODOMAIN_NOREWRITE_NOFRONT = find_api_root( - lstrip=True, set_domain=False, rewrite_header=False -) -_, PATH_NODOMAIN_REWRITE_NOFRONT = find_api_root(lstrip=True, set_domain=False, rewrite_header=True) +HUNDRED_DAYS = 100 * 24 * 60 * 60 + + +def _setup_vars(vers=REST_FRAMEWORK.get("DEFAULT_VERSION", "v3")): + _, PATH_DOMAIN_REWRITE_NOFRONT = find_api_root( + lstrip=True, set_domain=True, rewrite_header=True, version=vers + ) + _, PATH_NODOMAIN_NOREWRITE_NOFRONT = find_api_root( + lstrip=True, set_domain=False, rewrite_header=False, version=vers + ) + _, PATH_NODOMAIN_REWRITE_NOFRONT = find_api_root( + lstrip=True, set_domain=False, rewrite_header=True, version=vers + ) + return { + "PATH_DOMAIN_REWRITE_NOFRONT": PATH_DOMAIN_REWRITE_NOFRONT, + "PATH_NODOMAIN_NOREWRITE_NOFRONT": PATH_NODOMAIN_NOREWRITE_NOFRONT, + "PATH_NODOMAIN_REWRITE_NOFRONT": PATH_NODOMAIN_REWRITE_NOFRONT, + } + + +if ENABLE_V4_API: + VERSIONS = [r""] +else: + VERSIONS = [r"v3"] + +PATH_VARS = {} +for v in VERSIONS: + PATH_VARS[v] = _setup_vars(vers=v) + + +def _docs_and_status(version): + return [ + re_path(r"^livez/", LivezView.as_view()), + re_path(r"^status/$", StatusView.as_view()), + re_path( + r"^docs/api.json$", + cache_page(HUNDRED_DAYS)( + SpectacularJSONAPIView.as_view(authentication_classes=[], permission_classes=[]) + ), + name="schema", + ), + re_path( + r"^docs/api.yaml$", + cache_page(HUNDRED_DAYS)( + SpectacularYAMLAPIView.as_view(authentication_classes=[], permission_classes=[]) + ), + name="schema-yaml", + ), + re_path( + r"^docs/$", + cache_page(HUNDRED_DAYS)( + SpectacularRedocView.as_view( + authentication_classes=[], + permission_classes=[], + url=f"/{PATH_VARS[version]['PATH_NODOMAIN_NOREWRITE_NOFRONT']}docs/api.json?include_html=1&pk_path=1", + ) + ), + name="schema-redoc", + ), + re_path( + r"^swagger/$", + cache_page(HUNDRED_DAYS)( + SpectacularSwaggerView.as_view( + authentication_classes=[], + permission_classes=[], + url=f"/{PATH_VARS[version]['PATH_NODOMAIN_NOREWRITE_NOFRONT']}docs/api.json?include_html=1&pk_path=1", + ) + ), + name="schema-swagger", + ), + ] + + +for v in VERSIONS: + PATH_VARS[v]["docs_and_status"] = _docs_and_status(v) class ViewSetNode: @@ -152,103 +223,67 @@ class PulpDefaultRouter(routers.DefaultRouter): vs_tree.add_decendent(ViewSetNode(viewset)) special_views = [ - path("login/", LoginViewSet.as_view()), - path("repair/", RepairView.as_view()), - path("datarepair/7272/", DataRepair7272View.as_view()), - path( - "orphans/cleanup/", + re_path(r"^login/$", LoginViewSet.as_view()), + re_path(r"^repair/$", RepairView.as_view()), + re_path(r"^datarepair/7272/$", DataRepair7272View.as_view()), + re_path( + r"^orphans/cleanup/$", OrphansCleanupViewset.as_view(actions={"post": "cleanup"}), ), - path("orphans/", OrphansView.as_view()), - path( - "repository_versions/", + re_path(r"^orphans/$", OrphansView.as_view()), + re_path( + r"^repository_versions/$", ListRepositoryVersionViewSet.as_view(actions={"get": "list"}), ), - path( - "repositories/reclaim_space/", + re_path( + r"^repositories/reclaim_space/$", ReclaimSpaceViewSet.as_view(actions={"post": "reclaim"}), ), - path( - "importers/core/pulp/import-check/", + re_path( + r"^importers/core/pulp/import-check/$", PulpImporterImportCheckView.as_view(), ), ] -hundred_days = 100 * 24 * 60 * 60 - -docs_and_status = [ - path("livez/", LivezView.as_view()), - path("status/", StatusView.as_view()), - path( - "docs/api.json", - cache_page(hundred_days)( - SpectacularJSONAPIView.as_view(authentication_classes=[], permission_classes=[]) - ), - name="schema", - ), - path( - "docs/api.yaml", - cache_page(hundred_days)( - SpectacularYAMLAPIView.as_view(authentication_classes=[], permission_classes=[]) - ), - name="schema-yaml", - ), - path( - "docs/", - cache_page(hundred_days)( - SpectacularRedocView.as_view( - authentication_classes=[], - permission_classes=[], - url=f"/{PATH_NODOMAIN_NOREWRITE_NOFRONT}docs/api.json?include_html=1&pk_path=1", - ) - ), - name="schema-redoc", - ), - path( - "swagger/", - cache_page(hundred_days)( - SpectacularSwaggerView.as_view( - authentication_classes=[], - permission_classes=[], - url=f"/{PATH_NODOMAIN_NOREWRITE_NOFRONT}docs/api.json?include_html=1&pk_path=1", - ) - ), - name="schema-swagger", - ), -] - -urlpatterns = [ - path(PATH_DOMAIN_REWRITE_NOFRONT, include(special_views)), - path("auth/", include("rest_framework.urls")), - # docs/status aren't "inside" a domain - path(PATH_NODOMAIN_REWRITE_NOFRONT, include(docs_and_status)), -] - -if settings.DOMAIN_ENABLED: - # Ensure Docs and Status endpoints are available within domains, but are not shown in API schema - docs_and_status_no_schema = [] - for p in docs_and_status: - - @extend_schema(exclude=True) - class NoSchema(p.callback.cls): - pass - - view = NoSchema.as_view(**p.callback.initkwargs) - name = p.name + "-domains" if p.name else None - docs_and_status_no_schema.append(path(str(p.pattern), view, name=name)) - urlpatterns.insert(-1, path(PATH_DOMAIN_REWRITE_NOFRONT, include(docs_and_status_no_schema))) - +urlpatterns = [path("auth/", include("rest_framework.urls"))] if "social_django" in settings.INSTALLED_APPS: urlpatterns.append( path("", include("social_django.urls", namespace=settings.SOCIAL_AUTH_URL_NAMESPACE)) ) - -#: The Pulp Platform v3 API router, which can be used to manually register ViewSets with the API. -root_router = PulpDefaultRouter() - -all_routers = [root_router] + vs_tree.register_with(root_router) -for router in all_routers: - urlpatterns.append(path(PATH_DOMAIN_REWRITE_NOFRONT, include(router.urls))) +for v in VERSIONS: + tmp_list = [ + path(PATH_VARS[v]["PATH_DOMAIN_REWRITE_NOFRONT"], include(special_views)), + # docs/status aren't "inside" a domain + path( + PATH_VARS[v]["PATH_NODOMAIN_REWRITE_NOFRONT"], include(PATH_VARS[v]["docs_and_status"]) + ), + ] + urlpatterns.extend(tmp_list) + if settings.DOMAIN_ENABLED: + # Ensure Docs and Status endpoints are available within domains, but are not shown in API schema + docs_and_status_no_schema = [] + for p in PATH_VARS[v]["docs_and_status"]: + + @extend_schema(exclude=True) + class NoSchema(p.callback.cls): + pass + + view = NoSchema.as_view(**p.callback.initkwargs) + name = p.name + "-domains" if p.name else None + pattern = rf"^{str(p.pattern)}$" + docs_and_status_no_schema.append(re_path(pattern, view, name=name)) + urlpatterns.insert( + -1, + path(PATH_VARS[v]["PATH_DOMAIN_REWRITE_NOFRONT"], include(docs_and_status_no_schema)), + ) + +for v in VERSIONS: + # The Pulp Platform API router, which can be used to manually register ViewSets with the API. + root_router = PulpDefaultRouter() + + all_routers = [root_router] + vs_tree.register_with(root_router) + for router in all_routers: + urlpatterns.append(path(PATH_VARS[v]["PATH_DOMAIN_REWRITE_NOFRONT"], include(router.urls))) # If plugins define a urls.py, include them into the root namespace. for plugin_pattern in plugin_patterns: diff --git a/pulpcore/app/util.py b/pulpcore/app/util.py index 237ad50e67a..35229cc0c9c 100644 --- a/pulpcore/app/util.py +++ b/pulpcore/app/util.py @@ -22,8 +22,9 @@ from pulpcore.app import models from pulpcore.app.apps import pulp_plugin_configs -from pulpcore.app.contexts import _current_domain, _current_user_func +from pulpcore.app.contexts import _current_domain, _current_pulp_version, _current_user_func from pulpcore.app.loggers import deprecation_logger +from pulpcore.app.settings import ENABLE_V4_API, REST_FRAMEWORK from pulpcore.exceptions.validation import InvalidSignatureError # a little cache so viewset_for_model doesn't have to iterate over every app every time @@ -43,6 +44,16 @@ def reverse(viewname, args=None, kwargs=None, request=None, relative_url=True, * returned url is always relative. """ kwargs = kwargs or {} + if ENABLE_V4_API: + if request: + # Might be None if a plugin hasn't updated to using yet + kwargs["version"] = ( + request.version if request.version else REST_FRAMEWORK.get("DEFAULT_VERSION", "v3") + ) + else: + # If we have a curr-vers in a task-context this works. Otherwise, we're just + # going to get the current default-version here. + kwargs["version"] = _current_pulp_version.get() if settings.DOMAIN_ENABLED: kwargs.setdefault("pulp_domain", get_domain().name) if settings.API_ROOT_REWRITE_HEADER: diff --git a/pulpcore/app/views/datarepair.py b/pulpcore/app/views/datarepair.py index 63ab38654ee..7014e6f250f 100644 --- a/pulpcore/app/views/datarepair.py +++ b/pulpcore/app/views/datarepair.py @@ -23,7 +23,7 @@ class DataRepair7272View(APIView): request=DataRepair7272Serializer, responses={202: AsyncOperationResponseSerializer}, ) - def post(self, request): + def post(self, request, **kwargs): """ Repair repository version data issues (Issue #7272). """ diff --git a/pulpcore/app/views/importer.py b/pulpcore/app/views/importer.py index 5e5f104588a..7086e33df47 100644 --- a/pulpcore/app/views/importer.py +++ b/pulpcore/app/views/importer.py @@ -85,7 +85,7 @@ class PulpImporterImportCheckView(APIView): request=PulpImportCheckSerializer, responses={200: PulpImportCheckResponseSerializer}, ) - def post(self, request, format=None): + def post(self, request, format=None, **kwargs): """ Evaluates validity of proposed PulpImport parameters 'toc', 'path', and 'repo_mapping'. diff --git a/pulpcore/app/views/orphans.py b/pulpcore/app/views/orphans.py index 3c08330c09c..7bb7fccb3ca 100644 --- a/pulpcore/app/views/orphans.py +++ b/pulpcore/app/views/orphans.py @@ -15,7 +15,7 @@ class OrphansView(APIView): summary="Delete orphans", responses={202: AsyncOperationResponseSerializer}, ) - def delete(self, request, format=None): + def delete(self, request, format=None, **kwargs): """ Cleans up all the Content and Artifact orphans in the system """ diff --git a/pulpcore/app/views/repair.py b/pulpcore/app/views/repair.py index 4ff20dbf548..3699ab56c5b 100644 --- a/pulpcore/app/views/repair.py +++ b/pulpcore/app/views/repair.py @@ -17,7 +17,7 @@ class RepairView(APIView): request=RepairSerializer, responses={202: AsyncOperationResponseSerializer}, ) - def post(self, request): + def post(self, request, **kwargs): """ Repair artifacts. """ diff --git a/pulpcore/app/views/status.py b/pulpcore/app/views/status.py index 7d494e272f2..1abfa56f188 100644 --- a/pulpcore/app/views/status.py +++ b/pulpcore/app/views/status.py @@ -13,7 +13,8 @@ from pulpcore.app.models.content import Artifact from pulpcore.app.models.status import AppStatus from pulpcore.app.redis_connection import get_redis_connection -from pulpcore.app.serializers.status import StatusSerializer +from pulpcore.app.serializers.status import StatusSerializer, V4StatusSerializer +from pulpcore.app.settings import ENABLE_V4_API, REST_FRAMEWORK from pulpcore.app.util import get_domain _logger = logging.getLogger(__name__) @@ -47,7 +48,7 @@ class StatusView(APIView): operation_id="status_read", responses={200: StatusSerializer}, ) - def get(self, request): + def get(self, request, **kwargs): """ Returns status and app information about Pulp. @@ -100,7 +101,18 @@ def get(self, request): } context = {"request": request} - serializer = StatusSerializer(data, context=context) + + # If V4 is enabled, we'll set up to use the "old" serializer for v3 + # and the new one with api_version for anything else + if ENABLE_V4_API: + if request.version == "v3": + serializer = StatusSerializer(data, context=context) + else: + data["api_version"] = request.version + data["supported_api_versions"] = REST_FRAMEWORK["ALLOWED_VERSIONS"] + serializer = V4StatusSerializer(data, context=context) + else: + serializer = StatusSerializer(data, context=context) return Response(serializer.data) @staticmethod @@ -153,7 +165,7 @@ class LivezView(APIView): operation_id="livez_read", responses={200: None}, ) - def get(self, request): + def get(self, request, **kwargs): """ Returns 200 OK when API is alive. """ diff --git a/pulpcore/app/viewsets/access_policy.py b/pulpcore/app/viewsets/access_policy.py index e592123ad97..3746d332fe7 100644 --- a/pulpcore/app/viewsets/access_policy.py +++ b/pulpcore/app/viewsets/access_policy.py @@ -26,7 +26,7 @@ class AccessPolicyViewSet( @extend_schema(request=None) @action(detail=True, methods=["post"]) - def reset(self, request, pk=None): + def reset(self, request, pk=None, **kwargs): """ Reset the access policy to its uncustomized default value. """ diff --git a/pulpcore/app/viewsets/acs.py b/pulpcore/app/viewsets/acs.py index 2f992014842..5a618e590a2 100644 --- a/pulpcore/app/viewsets/acs.py +++ b/pulpcore/app/viewsets/acs.py @@ -38,7 +38,7 @@ class AlternateContentSourceViewSet( filterset_fields = {"name": NAME_FILTER_OPTIONS} @action(detail=True, methods=["post"]) - def refresh(self, request, pk=None): + def refresh(self, request, pk=None, **kwargs): raise NotImplementedError(_("Method not implemented by plugin writer!")) @extend_schema( diff --git a/pulpcore/app/viewsets/base.py b/pulpcore/app/viewsets/base.py index 7e09cc151d6..0b40303af95 100644 --- a/pulpcore/app/viewsets/base.py +++ b/pulpcore/app/viewsets/base.py @@ -27,6 +27,7 @@ SetLabelSerializer, UnsetLabelSerializer, ) +from pulpcore.app.settings import ENABLE_V4_API from pulpcore.app.util import get_viewset_for_model, resolve_prn from pulpcore.openapi import InheritSerializer, PulpAutoSchema from pulpcore.tasking.tasks import dispatch @@ -203,6 +204,8 @@ def get_resource(uri, model=None): kwargs["pulp_domain__name"] = value elif key == "api_root": continue + elif key == "version": # Skip API-version for finding model-instance + continue else: kwargs[key] = value @@ -421,7 +424,7 @@ class AsyncReservedObjectMixin: By default, lock the object instance we are working on. """ - def async_reserved_resources(self, instance): + def async_reserved_resources(self, instance, **kwargs): """ Return the resources to reserve for the task created by the Async...Mixins. @@ -466,11 +469,13 @@ def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) app_label = self.queryset.model._meta.app_label + task_kwargs = {"data": request.data} + task_kwargs.update(kwargs) task = dispatch( tasks.base.general_create, exclusive_resources=self.async_reserved_resources(None), args=(app_label, serializer.__class__.__name__), - kwargs={"data": request.data}, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) @@ -496,11 +501,17 @@ def update(self, request, pk, **kwargs): return Response(serializer.data) else: app_label = instance._meta.app_label + task_kwargs = { + "data": request.data, + "partial": partial, + } + if ENABLE_V4_API: + task_kwargs.update(kwargs) task = dispatch( tasks.base.ageneral_update, exclusive_resources=self.async_reserved_resources(instance), args=(pk, app_label, serializer.__class__.__name__), - kwargs={"data": request.data, "partial": partial}, + kwargs=task_kwargs, immediate=self.ALLOW_NON_BLOCKING_UPDATE, ) return OperationPostponedResponse(task, request) @@ -553,7 +564,7 @@ class RolesMixin: }, ) @action(detail=True, methods=["get"]) - def list_roles(self, request, pk): + def list_roles(self, request, pk, **kwargs): obj = self.get_object() obj_type = ContentType.objects.get_for_model(obj) user_qs = UserRole.objects.filter( @@ -588,7 +599,7 @@ def list_roles(self, request, pk): responses={201: NestedRoleSerializer}, ) @action(detail=True, methods=["post"], serializer_class=NestedRoleSerializer) - def add_role(self, request, pk): + def add_role(self, request, pk, **kwargs): obj = self.get_object() serializer = NestedRoleSerializer( data=request.data, context={"request": request, "content_object": obj, "assign": True} @@ -625,7 +636,7 @@ def add_role(self, request, pk): responses={201: NestedRoleSerializer}, ) @action(detail=True, methods=["post"], serializer_class=NestedRoleSerializer) - def remove_role(self, request, pk): + def remove_role(self, request, pk, **kwargs): obj = self.get_object() serializer = NestedRoleSerializer( data=request.data, context={"request": request, "content_object": obj, "assign": False} @@ -646,7 +657,7 @@ def remove_role(self, request, pk): }, ) @action(detail=True, methods=["get"]) - def my_permissions(self, request, pk=None): + def my_permissions(self, request, pk=None, **kwargs): obj = self.get_object() app_label = obj._meta.app_label permissions = [ @@ -661,7 +672,7 @@ class LabelsMixin: description="Set a single pulp_label on the object to a specific value or null.", ) @action(detail=True, methods=["post"], serializer_class=SetLabelSerializer) - def set_label(self, request, pk=None): + def set_label(self, request, pk=None, **kwargs): obj = self.get_object() serializer = SetLabelSerializer( data=request.data, context={"request": request, "content_object": obj} @@ -680,7 +691,7 @@ def set_label(self, request, pk=None): description="Unset a single pulp_label on the object.", ) @action(detail=True, methods=["post"], serializer_class=UnsetLabelSerializer) - def unset_label(self, request, pk=None): + def unset_label(self, request, pk=None, **kwargs): obj = self.get_object() serializer = UnsetLabelSerializer( data=request.data, context={"request": request, "content_object": obj} diff --git a/pulpcore/app/viewsets/content.py b/pulpcore/app/viewsets/content.py index 0fd5f7024f6..a23ad0e13dd 100644 --- a/pulpcore/app/viewsets/content.py +++ b/pulpcore/app/viewsets/content.py @@ -89,7 +89,7 @@ class ArtifactViewSet( # Deleting artifacts is a risky operation and will be removed in a future release. # However, for compatibility reasons, it is still possible to execute the DELETE # request by overriding the DEFAULT_ACCESS_POLICY. - def destroy(self, request, pk): + def destroy(self, request, pk, **kwargs): """ Remove Artifact only if it is not associated with any Content. """ diff --git a/pulpcore/app/viewsets/exporter.py b/pulpcore/app/viewsets/exporter.py index af2a5b2c824..b56ec5097d1 100644 --- a/pulpcore/app/viewsets/exporter.py +++ b/pulpcore/app/viewsets/exporter.py @@ -110,7 +110,7 @@ class PulpExportViewSet(ExportViewSet): description="Trigger an asynchronous task to export a set of repositories", responses={202: AsyncOperationResponseSerializer}, ) - def create(self, request, exporter_pk): + def create(self, request, exporter_pk, **kwargs): """ Generates a Task to export the set of repositories assigned to a specific PulpExporter. """ @@ -121,13 +121,14 @@ def create(self, request, exporter_pk): # Validate Export serializer = PulpExportSerializer(data=request.data, context={"exporter": exporter}) serializer.is_valid(raise_exception=True) - + task_kwargs = {"exporter_pk": str(exporter.pk), "params": request.data} + task_kwargs.update(kwargs) # Invoke the export task = dispatch( pulp_export, exclusive_resources=[exporter], shared_resources=exporter.repositories.all(), - kwargs={"exporter_pk": str(exporter.pk), "params": request.data}, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) @@ -147,7 +148,7 @@ class FilesystemExportViewSet(ExportViewSet): description="Trigger an asynchronous task to export files to the filesystem", responses={202: AsyncOperationResponseSerializer}, ) - def create(self, request, exporter_pk): + def create(self, request, exporter_pk, **kwargs): """ Generates a Task to export files to the filesystem. """ @@ -167,26 +168,29 @@ def create(self, request, exporter_pk): if request.data.get("publication"): publication = self.get_resource(request.data["publication"], Publication) + task_kwargs = { + "exporter_pk": exporter.pk, + "publication_pk": publication.pk, + "start_repo_version_pk": start_repository_version_pk, + } + task_kwargs.update(kwargs) task = dispatch( fs_publication_export, exclusive_resources=[exporter], - kwargs={ - "exporter_pk": exporter.pk, - "publication_pk": publication.pk, - "start_repo_version_pk": start_repository_version_pk, - }, + kwargs=task_kwargs, ) else: repo_version = self.get_resource(request.data["repository_version"], RepositoryVersion) - + task_kwargs = { + "exporter_pk": str(exporter.pk), + "repo_version_pk": repo_version.pk, + "start_repo_version_pk": start_repository_version_pk, + } + task_kwargs.update(kwargs) task = dispatch( fs_repo_version_export, exclusive_resources=[exporter], - kwargs={ - "exporter_pk": str(exporter.pk), - "repo_version_pk": repo_version.pk, - "start_repo_version_pk": start_repository_version_pk, - }, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) diff --git a/pulpcore/app/viewsets/importer.py b/pulpcore/app/viewsets/importer.py index a8a61222f86..e1cae49ba32 100644 --- a/pulpcore/app/viewsets/importer.py +++ b/pulpcore/app/viewsets/importer.py @@ -82,7 +82,7 @@ class PulpImportViewSet(ImportViewSet): description="Trigger an asynchronous task to import a Pulp export.", responses={202: TaskGroupOperationResponseSerializer}, ) - def create(self, request, importer_pk): + def create(self, request, importer_pk, **kwargs): """Import a Pulp export into Pulp.""" try: importer = PulpImporter.objects.get(pk=importer_pk) @@ -100,16 +100,17 @@ def create(self, request, importer_pk): task_group = TaskGroup.objects.create( description="Import of {path}".format(path=path or toc) ) - + task_kwargs = { + "importer_pk": importer.pk, + "path": path, + "toc": toc, + "create_repositories": create_repositories, + } + task_kwargs.update(**kwargs) dispatch( pulp_import, exclusive_resources=[importer], task_group=task_group, - kwargs={ - "importer_pk": importer.pk, - "path": path, - "toc": toc, - "create_repositories": create_repositories, - }, + kwargs=task_kwargs, ) return TaskGroupOperationResponse(task_group, request) diff --git a/pulpcore/app/viewsets/orphans.py b/pulpcore/app/viewsets/orphans.py index 6d9a72f2e07..e711574edfe 100644 --- a/pulpcore/app/viewsets/orphans.py +++ b/pulpcore/app/viewsets/orphans.py @@ -15,7 +15,7 @@ class OrphansCleanupViewset(ViewSet): description="Trigger an asynchronous orphan cleanup operation.", responses={202: AsyncOperationResponseSerializer}, ) - def cleanup(self, request): + def cleanup(self, request, **kwargs): """ Triggers an asynchronous orphan cleanup operation. """ diff --git a/pulpcore/app/viewsets/reclaim.py b/pulpcore/app/viewsets/reclaim.py index a2659e4f8e9..674db8e72f4 100644 --- a/pulpcore/app/viewsets/reclaim.py +++ b/pulpcore/app/viewsets/reclaim.py @@ -18,7 +18,7 @@ class ReclaimSpaceViewSet(ViewSet): description="Trigger an asynchronous space reclaim operation.", responses={202: AsyncOperationResponseSerializer}, ) - def reclaim(self, request): + def reclaim(self, request, **kwargs): """ Triggers an asynchronous space reclaim operation. """ @@ -40,14 +40,16 @@ def reclaim(self, request): else: exclusive_resources = [f"pdrn:{request.pulp_domain.pulp_id}:reclaim_space"] + task_kwargs = { + "repo_pks": reclaim_repo_pks, + "keeplist_rv_pks": keeplist_rv_pks, + } + task_kwargs.update(kwargs) task = dispatch( reclaim_space, exclusive_resources=exclusive_resources, shared_resources=repos, - kwargs={ - "repo_pks": reclaim_repo_pks, - "keeplist_rv_pks": keeplist_rv_pks, - }, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) diff --git a/pulpcore/app/viewsets/replica.py b/pulpcore/app/viewsets/replica.py index d644ee40f17..eb1637ade49 100644 --- a/pulpcore/app/viewsets/replica.py +++ b/pulpcore/app/viewsets/replica.py @@ -126,7 +126,7 @@ class UpstreamPulpViewSet( responses={202: TaskGroupOperationResponseSerializer}, ) @action(detail=True, methods=["post"], serializer_class=UpstreamPulpReplicateSerializer) - def replicate(self, request, pk): + def replicate(self, request, pk, **kwargs): """ Triggers an asynchronous repository replication operation. """ @@ -137,11 +137,10 @@ def replicate(self, request, pk): task_group = TaskGroup.objects.create(description=f"Replication of {server.name}") exclusive_resources = [f"pdrn:{request.pulp_domain.pulp_id}:servers"] - - task_kwargs = {"server_pk": pk} + task_kwargs = kwargs = {"server_pk": pk} if q_select := serializer.validated_data.get("q_select"): task_kwargs["q_select"] = q_select - + task_kwargs.update(kwargs) dispatch( replicate_distributions, exclusive_resources=exclusive_resources, diff --git a/pulpcore/app/viewsets/repository.py b/pulpcore/app/viewsets/repository.py index b3e73dfdb53..77017d870bc 100644 --- a/pulpcore/app/viewsets/repository.py +++ b/pulpcore/app/viewsets/repository.py @@ -272,7 +272,7 @@ class RepositoryVersionViewSet( description="Trigger an asynchronous task to delete a repository version.", responses={202: AsyncOperationResponseSerializer}, ) - def destroy(self, request, repository_pk, number): + def destroy(self, request, repository_pk, number, **kwargs): """ Queues a task to handle deletion of a RepositoryVersion """ @@ -280,11 +280,12 @@ def destroy(self, request, repository_pk, number): if version in version.repository.protected_versions(): raise serializers.ValidationError(PROTECTED_REPO_VERSION_MESSAGE) - + task_kwargs = {"pk": version.pk} + task_kwargs.update(kwargs) task = dispatch( tasks.repository.delete_version, exclusive_resources=[version.repository], - kwargs={"pk": version.pk}, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) @@ -293,7 +294,7 @@ def destroy(self, request, repository_pk, number): responses={202: AsyncOperationResponseSerializer}, ) @action(detail=True, methods=["post"], serializer_class=RepairSerializer) - def repair(self, request, repository_pk, number): + def repair(self, request, repository_pk, number, **kwargs): """ Queues a task to repair corrupted artifacts corresponding to a RepositoryVersion """ @@ -307,6 +308,7 @@ def repair(self, request, repository_pk, number): tasks.repository.repair_version, shared_resources=[version.repository], args=[version.pk, verify_checksums], + kwargs=kwargs, ) return OperationPostponedResponse(task, request) diff --git a/pulpcore/app/viewsets/task.py b/pulpcore/app/viewsets/task.py index c24131dd250..a68b26bffa5 100644 --- a/pulpcore/app/viewsets/task.py +++ b/pulpcore/app/viewsets/task.py @@ -222,7 +222,7 @@ def get_queryset(self): operation_id="tasks_cancel", responses={200: TaskSerializer, 409: TaskSerializer}, ) - def partial_update(self, request, pk=None, partial=True): + def partial_update(self, request, pk=None, partial=True, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -238,7 +238,7 @@ def partial_update(self, request, pk=None, partial=True): serializer = self.serializer_class(task, context={"request": request}) return Response(serializer.data, status=http_status) - def destroy(self, request, pk=None): + def destroy(self, request, pk=None, **kwargs): task = self.get_object() if task.state in TASK_INCOMPLETE_STATES: return Response(status=status.HTTP_409_CONFLICT) @@ -260,17 +260,19 @@ def get_serializer_class(self): responses={202: AsyncOperationResponseSerializer}, ) @action(detail=False, methods=["post"]) - def purge(self, request): + def purge(self, request, **kwargs): """ Purge task-records for tasks in 'final' states. """ serializer = PurgeSerializer(data=request.data) serializer.is_valid(raise_exception=True) current_user = get_current_user() + task_kwargs = {"user_pk": None if current_user is None else current_user.pk} + task_kwargs.update(kwargs) task = dispatch( purge, args=[serializer.data["finished_before"], list(serializer.data["states"])], - kwargs={"user_pk": None if current_user is None else current_user.pk}, + kwargs=task_kwargs, ) return OperationPostponedResponse(task, request) @@ -282,7 +284,7 @@ def purge(self, request): ), ) @action(detail=True) - def profile_artifacts(self, request, pk): + def profile_artifacts(self, request, pk, **kwargs): """ Return pre-signed URLs used for downloading raw profile artifacts. """ @@ -333,7 +335,7 @@ def scope_queryset(self, qs): request=TaskCancelSerializer, responses={200: TaskGroupSerializer, 409: TaskGroupSerializer}, ) - def partial_update(self, request, pk=None, partial=True): + def partial_update(self, request, pk=None, partial=True, **kwargs): TaskCancelSerializer(data=request.data, context={"request": request}).is_valid( raise_exception=True ) diff --git a/pulpcore/app/viewsets/upload.py b/pulpcore/app/viewsets/upload.py index 6f1128b706a..d01bc7de862 100644 --- a/pulpcore/app/viewsets/upload.py +++ b/pulpcore/app/viewsets/upload.py @@ -125,7 +125,7 @@ def get_serializer_context(self): parameters=[content_range_parameter], responses={200: UploadSerializer}, ) - def update(self, request, pk=None): + def update(self, request, pk=None, **kwargs): """ Upload a chunk for an upload. """ @@ -148,7 +148,7 @@ def update(self, request, pk=None): responses={202: AsyncOperationResponseSerializer}, ) @action(detail=True, methods=["post"]) - def commit(self, request, pk): + def commit(self, request, pk, **kwargs): """ Queues a Task that creates an Artifact, and the Upload gets deleted and cannot be re-used. """ diff --git a/pulpcore/app/viewsets/user.py b/pulpcore/app/viewsets/user.py index a8a6e4f295f..d16dc54943d 100644 --- a/pulpcore/app/viewsets/user.py +++ b/pulpcore/app/viewsets/user.py @@ -183,7 +183,7 @@ class GroupUserViewSet(NamedModelViewSet): "creation_hooks": [], } - def list(self, request, group_pk): + def list(self, request, group_pk, **kwargs): """ List group users. """ @@ -198,7 +198,7 @@ def list(self, request, group_pk): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - def create(self, request, group_pk): + def create(self, request, group_pk, **kwargs): """ Add a user to a group. """ @@ -218,7 +218,7 @@ def create(self, request, group_pk): serializer = GroupUserSerializer(user, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) - def destroy(self, request, group_pk, pk): + def destroy(self, request, group_pk, pk, **kwargs): """ Remove a user from a group. """ @@ -444,11 +444,11 @@ def urlpattern(): return "login" @extend_schema(operation_id="login_read") - def get(self, request): + def get(self, request, **kwargs): return Response(self.get_serializer(request.user).data) @extend_schema(operation_id="logout") - def delete(self, request): + def delete(self, request, **kwargs): auth_logout(request) return Response(status=204) @@ -457,7 +457,7 @@ def delete(self, request): request=LoginUpdateSerializer, responses={200: LoginUpdateSerializer}, ) - def patch(self, request): + def patch(self, request, **kwargs): instance = request.user serializer = LoginUpdateSerializer( instance, data=request.data, context={"request": request}, partial=True diff --git a/pulpcore/openapi/__init__.py b/pulpcore/openapi/__init__.py index 29106f6311b..061ec80fe42 100644 --- a/pulpcore/openapi/__init__.py +++ b/pulpcore/openapi/__init__.py @@ -34,21 +34,40 @@ from pulpcore.app.apps import pulp_plugin_configs from pulpcore.app.loggers import deprecation_logger +from pulpcore.app.settings import ENABLE_V4_API from pulpcore.plugin.find_url import find_api_root # Get the API-ROOT for this installation -_unused, FULL_API_PATH_NOFRONT = find_api_root(lstrip=True) +# We handle V3 seperately, since it existed before the "we can support multiple versions" work + +if ENABLE_V4_API: + _unused, FULL_API_PATH_NOFRONT = find_api_root(lstrip=True, version="") + _unused, V3_PATH_NOFRONT = find_api_root(lstrip=True, version="v3") +else: + # "Hardcoded" 'v3' for version, everywhere + _unused, FULL_API_PATH_NOFRONT = find_api_root(lstrip=True, version="v3") + _unused, V3_PATH_NOFRONT = find_api_root(lstrip=True, version="v3") + # Massage some api-affecting vars to "genericize" them for the spec if settings.DOMAIN_ENABLED: FULL_API_PATH_NOFRONT = FULL_API_PATH_NOFRONT.replace("slug:", "") + V3_PATH_NOFRONT = V3_PATH_NOFRONT.replace("slug:", "") + if settings.API_ROOT_REWRITE_HEADER: FULL_API_PATH_NOFRONT = FULL_API_PATH_NOFRONT.replace( "", settings.API_ROOT.strip("/") ) + V3_PATH_NOFRONT = V3_PATH_NOFRONT.replace("", settings.API_ROOT.strip("/")) # Final massage to make api-root "openapi compatible" FULL_API_PATH_NOFRONT = FULL_API_PATH_NOFRONT.replace("<", "{").replace(">", "}") +V3_PATH_NOFRONT = V3_PATH_NOFRONT.replace("<", "{").replace(">", "}") + +DOMAIN_STRIPPED_PREFIX = FULL_API_PATH_NOFRONT.replace("{pulp_domain}/", "") +V3_DOMAIN_STRIPPED_PREFIX = V3_PATH_NOFRONT.replace("{pulp_domain}/", "") +V4_DOMAIN_STRIPPED_PREFIX = V3_DOMAIN_STRIPPED_PREFIX.replace("v3", "v4") + # Python does not distinguish integer sizes. The safest assumption is that they are large. extend_schema_field(OpenApiTypes.INT64)(serializers.IntegerField) @@ -68,7 +87,6 @@ class PulpAutoSchema(AutoSchema): "patch": "partial_update", "delete": "delete", } - V3_API = FULL_API_PATH_NOFRONT.replace("{pulp_domain}/", "") def _tokenize_path(self): """ @@ -96,8 +114,17 @@ def _tokenize_path(self): if not tokenized_path and getattr(self.view, "get_view_name", None): tokenized_path.extend(self.view.get_view_name().split()) - path = "/".join(tokenized_path).replace(self.V3_API, "") - tokenized_path = path.split("/") + # We want to drop {pulp_domain} here. + # In addition, we want to drop V3 from the operationId here - but *only* if we find V3! + if "v3" in tokenized_path: + path = "/".join(tokenized_path).replace(V3_DOMAIN_STRIPPED_PREFIX, "") + tokenized_path = path.split("/") + elif "v4" in tokenized_path: + path = "/".join(tokenized_path).replace(V4_DOMAIN_STRIPPED_PREFIX, "") + tokenized_path = path.split("/") + else: + path = "/".join(tokenized_path).replace(DOMAIN_STRIPPED_PREFIX, "") + tokenized_path = path.split("/") return tokenized_path @@ -119,13 +146,19 @@ class MyViewSet(ViewSet): """ pulp_tag_name = getattr(self.view, "pulp_tag_name", False) + if pulp_tag_name: return [pulp_tag_name] tokenized_path = self._tokenize_path() subpath = "/".join(tokenized_path) - operation_keys = subpath.replace(self.V3_API, "").split("/") + if "v3" in subpath: + operation_keys = subpath.replace(V3_DOMAIN_STRIPPED_PREFIX, "").split("/") + elif "v4" in subpath: + operation_keys = subpath.replace(V4_DOMAIN_STRIPPED_PREFIX, "").split("/") + else: + operation_keys = subpath.replace(DOMAIN_STRIPPED_PREFIX, "").split("/") operation_keys = [i.title() for i in operation_keys] if len(operation_keys) > 2: del operation_keys[1] @@ -352,6 +385,8 @@ def convert_endpoint_path_params(self, path, view, schema): def parse(self, input_request, public): """Iterate endpoints generating per method path operations.""" + if ENABLE_V4_API: + in_version = input_request.version if hasattr(input_request, "version") else "v3" result = {} self._initialise_endpoints() endpoints = self._get_paths_and_endpoints() @@ -386,6 +421,9 @@ def parse(self, input_request, public): if settings.API_ROOT_REWRITE_HEADER: path = path.replace("{api_root}", settings.API_ROOT.strip("/")) + if ENABLE_V4_API: + path = path.replace("{version}", in_version) + if input_request is None or "pk_path" not in query_params: path = self.convert_endpoint_path_params(path, view, schema) @@ -407,7 +445,9 @@ def parse(self, input_request, public): tokenized_path = "_".join( [t.replace("-", "_").replace("/", "_").lower() for t in tokenized_path] ) + action = schema.get_operation_id_action() + if f"{tokenized_path}_{action}" == operation["operationId"]: operation["operationId"] = action diff --git a/pulpcore/plugin/actions.py b/pulpcore/plugin/actions.py index 23ebb55da36..8d80affd066 100644 --- a/pulpcore/plugin/actions.py +++ b/pulpcore/plugin/actions.py @@ -27,7 +27,7 @@ class ModifyRepositoryActionMixin: responses={202: AsyncOperationResponseSerializer}, ) @action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer) - def modify(self, request, pk): + def modify(self, request, pk, **kwargs): """ Queues a task that creates a new RepositoryVersion by adding and removing content units """ diff --git a/pulpcore/plugin/viewsets/content.py b/pulpcore/plugin/viewsets/content.py index 5530f9d5ada..9ecc2a450d3 100644 --- a/pulpcore/plugin/viewsets/content.py +++ b/pulpcore/plugin/viewsets/content.py @@ -36,7 +36,7 @@ class NoArtifactContentViewSet(DefaultDeferredContextMixin, ContentViewSet): "optionally create new repository version.", responses={202: AsyncOperationResponseSerializer}, ) - def create(self, request): + def create(self, request, **kwargs): """Create a content unit.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -65,7 +65,7 @@ class NoArtifactContentUploadViewSet(DefaultDeferredContextMixin, ContentViewSet "optionally create new repository version.", responses={202: AsyncOperationResponseSerializer}, ) - def create(self, request): + def create(self, request, **kwargs): """Create a content unit.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -100,7 +100,7 @@ class SingleArtifactContentUploadViewSet(DefaultDeferredContextMixin, ContentVie "optionally create new repository version.", responses={202: AsyncOperationResponseSerializer}, ) - def create(self, request): + def create(self, request, **kwargs): """Create a content unit.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/pulpcore/tasking/tasks.py b/pulpcore/tasking/tasks.py index dfaf5a1614d..73c29610b2d 100644 --- a/pulpcore/tasking/tasks.py +++ b/pulpcore/tasking/tasks.py @@ -22,6 +22,7 @@ from pulpcore.app.contexts import awith_task_context, with_task_context, x_task_diagnostics_var from pulpcore.app.loggers import deprecation_logger from pulpcore.app.models import AppStatus, Task, TaskGroup +from pulpcore.app.settings import REST_FRAMEWORK from pulpcore.app.util import ( get_domain, get_prn, @@ -407,6 +408,7 @@ def get_task_payload( function_name, task_group, args, kwargs, resources, versions, immediate, deferred, app_lock ): """Create arguments for creation of a new task""" + default_vers = REST_FRAMEWORK.get("DEFAULT_VERSION", "v3") payload = { "state": TASK_STATES.WAITING, "logging_cid": (get_guid()), @@ -421,6 +423,7 @@ def get_task_payload( "deferred": deferred, "profile_options": x_task_diagnostics_var.get(None), "app_lock": app_lock, + "version": kwargs.get("version", default_vers) if kwargs else default_vers, } return payload diff --git a/pulpcore/tests/functional/api/test_v4_status.py b/pulpcore/tests/functional/api/test_v4_status.py new file mode 100644 index 00000000000..ce2a9582b7c --- /dev/null +++ b/pulpcore/tests/functional/api/test_v4_status.py @@ -0,0 +1,29 @@ +import pytest +import requests + +from pulpcore.app import settings + + +@pytest.mark.parallel +@pytest.mark.skipif( + not settings.ENABLE_V4_API, + reason="Test is pointless if V4 isn't enabled", +) +@pytest.mark.parametrize( + "version,expect_pass,new_fields", + [("v3", True, False), ("v4", True, True), ("v5", False, False)], +) +def test_v4_status(version, expect_pass, new_fields, pulp_api_v3_url, pulp_settings): + v3_status_url = f"{pulp_api_v3_url}status/" + status_url = v3_status_url.replace("v3", version) + response = requests.get(status_url) + if expect_pass: + assert response.status_code == 200 + if new_fields: + status_dict = response.json() + assert "api_version" in status_dict + assert status_dict["api_version"] == version + assert "supported_api_versions" in status_dict + else: + assert response.status_code == 404 + assert response.json()["detail"] == "Invalid version in URL path."