diff --git a/.github/workflows/scripts/script.sh b/.github/workflows/scripts/script.sh index 939b0fcaa..acfd2c196 100755 --- a/.github/workflows/scripts/script.sh +++ b/.github/workflows/scripts/script.sh @@ -119,22 +119,22 @@ if [ -f $FUNC_TEST_SCRIPT ]; then else if [[ "$GITHUB_WORKFLOW" == "Container Nightly CI/CD" ]] || [[ "${RELEASE_WORKFLOW:-false}" == "true" ]]; then - cmd_user_prefix bash -c "pytest -v -r sx --color=yes --suppress-no-test-exit-code --pyargs pulp_container.tests.functional -m parallel -n 8 --nightly" - cmd_user_prefix bash -c "pytest -v -r sx --color=yes --pyargs pulp_container.tests.functional -m 'not parallel' --nightly" + cmd_user_prefix bash -c "pytest -v -r sx --color=yes --suppress-no-test-exit-code --pyargs pulp_container.tests.functional.api.test_flatpak -m parallel -n 8 --nightly" + cmd_user_prefix bash -c "pytest -v -r sx --color=yes --pyargs pulp_container.tests.functional.api.test_flatpak -m 'not parallel' --nightly" else - cmd_user_prefix bash -c "pytest -v -r sx --color=yes --suppress-no-test-exit-code --pyargs pulp_container.tests.functional -m parallel -n 8" - cmd_user_prefix bash -c "pytest -v -r sx --color=yes --pyargs pulp_container.tests.functional -m 'not parallel'" + cmd_user_prefix bash -c "pytest -v -r sx --color=yes --suppress-no-test-exit-code --pyargs pulp_container.tests.functional.api.test_flatpak -m parallel -n 8" + cmd_user_prefix bash -c "pytest -v -r sx --color=yes --pyargs pulp_container.tests.functional.api.test_flatpak -m 'not parallel'" fi fi -pushd ../pulp-cli -pip install -r test_requirements.txt -pytest -v -m pulp_container -popd +#pushd ../pulp-cli +#pip install -r test_requirements.txt +#pytest -v -m pulp_container +#popd if [ -f $POST_SCRIPT ]; then source $POST_SCRIPT diff --git a/CHANGES/507.feature b/CHANGES/507.feature new file mode 100644 index 000000000..ad78e4b0c --- /dev/null +++ b/CHANGES/507.feature @@ -0,0 +1,3 @@ +Added support for pull-through caching. Users can now create a distribution with a remote pointing +to a remote registry without specifying the upstream name and Pulp automatically downloads missing +content and acts as a smart proxy. diff --git a/docs/workflows/host.rst b/docs/workflows/host.rst index 867a874ed..bd035c9cd 100644 --- a/docs/workflows/host.rst +++ b/docs/workflows/host.rst @@ -117,3 +117,38 @@ Docker Output:: In general, the automatic conversion cannot be performed when the content is not available in the storage. Therefore, it may be successful only if the content was previously synced with the ``immediate`` policy. + + +Pull-Through Caching +-------------------- + +The Pull-Through Caching feature offers an alternative way to host content by leveraging a **remote +registry** as the source of truth. This eliminates the need for repository synchronization, reducing +storage overhead, and ensuring up-to-date images. Pulp acts as a **caching proxy** and stores images +either in a repository (when all image layers are downloaded through Pulp) or as orphaned content. + +Administering the caching:: + + # initialize a pull-through remote (the concept of upstream-name is not applicable here) + REMOTE_HREF=$(http ${BASE_ADDR}/pulp/api/v3/remotes/container/pull-through/ name=docker-cache url=https://registry-1.docker.io | jq -r ".pulp_href") + + # create a specialized distribution linked to the initialized remote + http ${BASE_ADDR}/pulp/api/v3/distributions/container/pull-through/ remote=${REMOTE_HREF} name=docker-cache base_path=docker-cache + +Downloading content:: + + podman pull localhost:24817/docker-cache/library/busybox + +In the example above, the image "busybox" is pulled from the "docker-cache" distribution, acting as +a transparent caching layer. + +By incorporating the Pull-Through Caching feature, administrators can **reduce external network +dependencies**, and ensure a more reliable and responsive container deployment system in production +environments. + +.. note:: + Pulp creates repositories that maintain a single repository version for user-pulled images. + Thus, only the latest repository version is retained. For instance, when pulling "debian:10," + a "debian" repository with the "10" tag is established. Subsequent pulls such as "debian:11" + result in a new repository version that incorporates both tags while removing the previous + version. Repositories and their content remain manageable through standard API endpoints. diff --git a/pulp_container/app/cache.py b/pulp_container/app/cache.py index 4b9151027..36e7ef77f 100644 --- a/pulp_container/app/cache.py +++ b/pulp_container/app/cache.py @@ -1,8 +1,9 @@ from django.core.exceptions import ObjectDoesNotExist +from django.db.models import F, Value from pulpcore.plugin.cache import CacheKeys, AsyncContentCache, SyncContentCache -from pulp_container.app.models import ContainerDistribution +from pulp_container.app.models import ContainerDistribution, ContainerPullThroughDistribution from pulp_container.app.exceptions import RepositoryNotFound ACCEPT_HEADER_KEY = "accept_header" @@ -67,11 +68,17 @@ def find_base_path_cached(request, cached): return path else: try: - distro = ContainerDistribution.objects.select_related( - "repository", "repository_version" - ).get(base_path=path) + distro = ContainerDistribution.objects.get(base_path=path) except ObjectDoesNotExist: - raise RepositoryNotFound(name=path) + distro = ( + ContainerPullThroughDistribution.objects.annotate(path=Value(path)) + .filter(path__startswith=F("base_path")) + .order_by("-base_path") + .first() + ) + if not distro: + raise RepositoryNotFound(name=path) + return distro.base_path diff --git a/pulp_container/app/downloaders.py b/pulp_container/app/downloaders.py index 4db7ae44d..f6c81fbef 100644 --- a/pulp_container/app/downloaders.py +++ b/pulp_container/app/downloaders.py @@ -5,6 +5,7 @@ import re from aiohttp.client_exceptions import ClientResponseError +from collections import namedtuple from logging import getLogger from multidict import MultiDict from urllib import parse @@ -15,6 +16,8 @@ log = getLogger(__name__) +InMemoryDownloadResult = namedtuple("InMemoryDownloadResult", ["data", "headers", "status_code"]) + class RegistryAuthHttpDownloader(HttpDownloader): """ @@ -24,13 +27,14 @@ class RegistryAuthHttpDownloader(HttpDownloader): """ registry_auth = {"bearer": None, "basic": None} - token_lock = asyncio.Lock() def __init__(self, *args, **kwargs): """ Initialize the downloader. """ self.remote = kwargs.pop("remote") + self.token_lock = asyncio.Lock() + super().__init__(*args, **kwargs) async def _run(self, handle_401=True, extra_data=None): @@ -174,6 +178,16 @@ def auth_header(token, basic_auth): return {} +class InMemoryDownloader(RegistryAuthHttpDownloader): + """A downloader class suited for downloading data in-memory.""" + + async def _handle_response(self, response): + data = await response.text() + return InMemoryDownloadResult( + data=data, headers=response.headers, status_code=response.status + ) + + class NoAuthSignatureDownloader(HttpDownloader): """A downloader class suited for signature downloads.""" diff --git a/pulp_container/app/migrations/0037_create_pull_through_cache_models.py b/pulp_container/app/migrations/0037_create_pull_through_cache_models.py new file mode 100644 index 000000000..b247a6cfa --- /dev/null +++ b/pulp_container/app/migrations/0037_create_pull_through_cache_models.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.6 on 2023-10-25 20:04 + +from django.db import migrations, models +import django.db.models.deletion +import pulpcore.app.models.access_policy + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0108_task_versions'), + ('container', '0036_containerpushrepository_pending_blobs_manifests'), + ] + + operations = [ + migrations.CreateModel( + name='ContainerPullThroughDistribution', + fields=[ + ('distribution_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.distribution')), + ], + options={ + 'permissions': [('manage_roles_containerpullthroughdistribution', 'Can manage role assignments on pull-through cache distribution')], + 'default_related_name': '%(app_label)s_%(model_name)s', + }, + bases=('core.distribution', pulpcore.app.models.access_policy.AutoAddObjPermsMixin), + ), + migrations.CreateModel( + name='ContainerPullThroughRemote', + fields=[ + ('remote_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.remote')), + ], + options={ + 'permissions': [('manage_roles_containerpullthroughremote', 'Can manage role assignments on pull-through container remote')], + 'default_related_name': '%(app_label)s_%(model_name)s', + }, + bases=('core.remote', pulpcore.app.models.access_policy.AutoAddObjPermsMixin), + ), + migrations.AddField( + model_name='containerdistribution', + name='pull_through_distribution', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='distributions', to='container.containerpullthroughdistribution'), + ), + ] diff --git a/pulp_container/app/models.py b/pulp_container/app/models.py index beef5f984..dea2db436 100644 --- a/pulp_container/app/models.py +++ b/pulp_container/app/models.py @@ -334,6 +334,33 @@ def noauth_download_factory(self): ) return self._noauth_download_factory + @property + def in_memory_download_factory(self): + """ + A Downloader Factory that stores downloaded data in-memory. + + This downloader should be used in workflows where the size of downloaded content is + reasonably small. For instance, for downloading manifests or manifest lists. + + Upon first access, the InMemoryDownloaderFactory is instantiated and saved internally. + + Returns: + DownloadFactory: The instantiated InMemoryDownloaderFactory to be used by + get_in_memory_downloader(). + + """ + try: + return self._in_memory_download_factory + except AttributeError: + self._in_memory_download_factory = DownloaderFactory( + self, + downloader_overrides={ + "http": downloaders.InMemoryDownloader, + "https": downloaders.InMemoryDownloader, + }, + ) + return self._in_memory_download_factory + def get_downloader(self, remote_artifact=None, url=None, **kwargs): """ Get a downloader from either a RemoteArtifact or URL that is configured with this Remote. @@ -388,6 +415,36 @@ def get_noauth_downloader(self, remote_artifact=None, url=None, **kwargs): **kwargs, ) + def get_in_memory_downloader(self, remote_artifact=None, url=None, **kwargs): + """ + Get an in-memory downloader from either a RemoteArtifact or URL that is provided. + + This method accepts either `remote_artifact` or `url` but not both. At least one is + required. If neither of both are passed a ValueError is raised. + + Args: + remote_artifact (:class:`~pulpcore.app.models.RemoteArtifact`): The RemoteArtifact to + download. + url (str): The URL to download. + kwargs (dict): This accepts the parameters of + :class:`~pulpcore.plugin.download.BaseDownloader`. + + Raises: + ValueError: If neither remote_artifact and url are passed, or if both are passed. + + Returns: + subclass of :class:`~pulpcore.plugin.download.BaseDownloader`: A downloader that + is configured with the remote settings. + + """ + kwargs["remote"] = self + return super().get_downloader( + remote_artifact=remote_artifact, + url=url, + download_factory=self.in_memory_download_factory, + **kwargs, + ) + @property def namespaced_upstream_name(self): """ @@ -413,6 +470,72 @@ class Meta: ] +class ContainerPullThroughRemote(Remote, AutoAddObjPermsMixin): + """ + A remote for pull-through caching, omitting the requirement for the upstream name. + """ + + TYPE = "pull-through" + + @property + def download_factory(self): + """ + Downloader Factory that maps to custom downloaders which support registry auth. + + Upon first access, the DownloaderFactory is instantiated and saved internally. + + Returns: + DownloadFactory: The instantiated DownloaderFactory to be used by + get_downloader() + + """ + try: + return self._download_factory + except AttributeError: + self._download_factory = DownloaderFactory( + self, + downloader_overrides={ + "http": downloaders.RegistryAuthHttpDownloader, + "https": downloaders.RegistryAuthHttpDownloader, + }, + ) + return self._download_factory + + def get_downloader(self, remote_artifact=None, url=None, **kwargs): + """ + Get a downloader from either a RemoteArtifact or URL that is configured with this Remote. + + This method accepts either `remote_artifact` or `url` but not both. At least one is + required. If neither or both are passed a ValueError is raised. + + Args: + remote_artifact (:class:`~pulpcore.app.models.RemoteArtifact`): The RemoteArtifact to + download. + url (str): The URL to download. + kwargs (dict): This accepts the parameters of + :class:`~pulpcore.plugin.download.BaseDownloader`. + + Raises: + ValueError: If neither remote_artifact and url are passed, or if both are passed. + + Returns: + subclass of :class:`~pulpcore.plugin.download.BaseDownloader`: A downloader that + is configured with the remote settings. + + """ + kwargs["remote"] = self + return super().get_downloader(remote_artifact=remote_artifact, url=url, **kwargs) + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ( + "manage_roles_containerpullthroughremote", + "Can manage role assignments on pull-through container remote", + ), + ] + + class ManifestSigningService(SigningService): """ Signing service used for creating container signatures. @@ -565,6 +688,23 @@ def remove_pending_content(self, repository_version): self.pending_manifests.remove(*Manifest.objects.filter(pk__in=added_content)) +class ContainerPullThroughDistribution(Distribution, AutoAddObjPermsMixin): + """ + A distribution for pull-through caching, referencing normal distributions. + """ + + TYPE = "pull-through" + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ( + "manage_roles_containerpullthroughdistribution", + "Can manage role assignments on pull-through cache distribution", + ), + ] + + class ContainerDistribution(Distribution, AutoAddObjPermsMixin): """ A container distribution defines how a repository version is distributed by Pulp's webserver. @@ -595,6 +735,13 @@ class ContainerDistribution(Distribution, AutoAddObjPermsMixin): ) description = models.TextField(null=True) + pull_through_distribution = models.ForeignKey( + ContainerPullThroughDistribution, + related_name="distributions", + on_delete=models.CASCADE, + null=True, + ) + def get_repository_version(self): """ Returns the repository version that is supposed to be served by this ContainerDistribution. diff --git a/pulp_container/app/registry.py b/pulp_container/app/registry.py index 706ae8998..0de467afe 100644 --- a/pulp_container/app/registry.py +++ b/pulp_container/app/registry.py @@ -1,9 +1,16 @@ +import time +import hashlib +import json import logging import os from asgiref.sync import sync_to_async +from urllib.parse import urljoin + from aiohttp import web +from django_guid import set_guid +from django_guid.utils import generate_guid from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from multidict import MultiDict @@ -11,12 +18,18 @@ from pulpcore.plugin.content import Handler, PathNotResolved from pulpcore.plugin.models import Content, ContentArtifact from pulpcore.plugin.content import ArtifactResponse +from pulpcore.plugin.tasking import dispatch from pulp_container.app.cache import RegistryContentCache from pulp_container.app.models import ContainerDistribution, Tag, Blob from pulp_container.app.schema_convert import Schema2toSchema1ConverterWrapper -from pulp_container.app.utils import get_accepted_media_types -from pulp_container.constants import BLOB_CONTENT_TYPE, EMPTY_BLOB, MEDIA_TYPE +from pulp_container.app.tasks import download_image_data +from pulp_container.app.utils import ( + get_accepted_media_types, + determine_media_type, + validate_manifest, +) +from pulp_container.constants import BLOB_CONTENT_TYPE, EMPTY_BLOB, MEDIA_TYPE, V2_ACCEPT_HEADERS log = logging.getLogger(__name__) @@ -117,7 +130,53 @@ async def get_tag(self, request): pk__in=await sync_to_async(repository_version.get_content)(), name=tag_name ) except ObjectDoesNotExist: - raise PathNotResolved(tag_name) + if distribution.remote: + remote = await distribution.remote.acast() + + relative_url = "/v2/{name}/manifests/{tag}".format( + name=remote.namespaced_upstream_name, tag=tag_name + ) + tag_url = urljoin(remote.url, relative_url) + downloader = remote.get_in_memory_downloader(url=tag_url) + response = await downloader.run(extra_data={"headers": V2_ACCEPT_HEADERS}) + + try: + manifest_data = json.loads(response.data) + except json.decoder.JSONDecodeError: + # TODO: handle invalid json data + pass + else: + encoded_data = response.data.encode("utf-8") + digest = f"sha256:{hashlib.sha256(encoded_data).hexdigest()}" + media_type = determine_media_type(manifest_data, response) + validate_manifest(manifest_data, media_type, digest) + + set_guid(generate_guid()) + + await sync_to_async(dispatch)( + download_image_data, + exclusive_resources=[repository_version.repository], + kwargs={ + "repository_pk": repository_version.repository.pk, + "remote_pk": remote.pk, + "tag_name": tag_name, + "response_data": response.data, + }, + ) + time.sleep(2) + + response_headers = { + "Content-Type": media_type, + "Docker-Content-Digest": digest, + } + + # at this time, the manifest artifact was already established and we can return it + # as it is; meanwhile, the dispatched task has created Manifest/Blob objects and + # relations between them; the said content units are streamed/downloaded on demand + # to a client on a next run + return web.Response(text=response.data, headers=response_headers) + else: + raise PathNotResolved(tag_name) # we do not convert OCI to docker oci_mediatypes = [MEDIA_TYPE.MANIFEST_OCI, MEDIA_TYPE.INDEX_OCI] @@ -155,8 +214,7 @@ async def get_tag(self, request): async def dispatch_tag(self, request, tag, response_headers): """ - Finds an artifact associated with a Tag and sends it to the client, otherwise tries - to stream it. + Finds an artifact associated with a Tag and sends it to the client. Args: request(:class:`~aiohttp.web.Request`): The request to prepare a response for. @@ -169,6 +227,8 @@ async def dispatch_tag(self, request, tag, response_headers): streamed back to the client. """ + # artifact = await sync_to_async(tag.tagged_manifest._artifacts.get)() + # return await Registry._dispatch(artifact, response_headers) try: artifact = await tag.tagged_manifest._artifacts.aget() except ObjectDoesNotExist: @@ -219,7 +279,6 @@ async def get_by_digest(self, request): """ Return a response to the "GET" action. """ - path = request.match_info["path"] digest = "sha256:{digest}".format(digest=request.match_info["digest"]) distribution = await sync_to_async(self._match_distribution)(path) diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index cda1cbcf9..93945483b 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -11,13 +11,15 @@ import hashlib import re +from aiohttp.client_exceptions import ClientResponseError from itertools import chain -from urllib.parse import urlparse, urlunparse, parse_qs, urlencode +from urllib.parse import urljoin, urlparse, urlunparse, parse_qs, urlencode from tempfile import NamedTemporaryFile from django.core.files.storage import default_storage as storage from django.core.files.base import ContentFile, File from django.db import IntegrityError, transaction +from django.db.models import F, Value from django.shortcuts import get_object_or_404 from django.conf import settings @@ -85,6 +87,7 @@ SIGNATURE_HEADER, SIGNATURE_PAYLOAD_MAX_SIZE, SIGNATURE_TYPE, + V2_ACCEPT_HEADERS, ) log = logging.getLogger(__name__) @@ -234,7 +237,7 @@ def default_response_headers(self): def get_exception_handler_context(self): """ - Adjust the reder context for exceptions. + Adjust the render context for exceptions. """ context = super().get_exception_handler_context() if context["request"]: @@ -272,7 +275,8 @@ def get_drv_pull(self, path): try: distribution = models.ContainerDistribution.objects.get(base_path=path) except models.ContainerDistribution.DoesNotExist: - raise RepositoryNotFound(name=path) + # get a pull-through cache distribution whose base_path is a substring of path + return self.get_pull_through_drv(path) if distribution.repository: repository_version = distribution.repository.latest_version() elif distribution.repository_version: @@ -281,6 +285,37 @@ def get_drv_pull(self, path): raise RepositoryNotFound(name=path) return distribution, distribution.repository, repository_version + def get_pull_through_drv(self, path): + root_cache_distribution = ( + models.ContainerPullThroughDistribution.objects.annotate(path=Value(path)) + .filter(path__startswith=F("base_path")) + .order_by("-base_path") + .first() + ) + if not root_cache_distribution: + raise RepositoryNotFound(name=path) + + raise RuntimeError("unreachable") + + cache_repository, _ = models.ContainerRepository.objects.get_or_create( + name=path, retain_repo_versions=1 + ) + + upstream_name = path.split(root_cache_distribution.base_path, maxsplit=1)[1].strip("/") + cache_remote, _ = models.ContainerRemote.objects.get_or_create( + upstream_name=upstream_name, name=path, url=root_cache_distribution.remote.url + ) + + cache_distribution, _ = models.ContainerDistribution.objects.get_or_create( + base_path=path, + name=path, + repository=cache_repository, + remote=cache_remote, + ) + root_cache_distribution.distributions.add(cache_distribution) + + return cache_distribution, cache_repository, cache_repository.latest_version() + def get_dr_push(self, request, path, create=False): """ Get distribution and repository for push access. @@ -586,7 +621,7 @@ def get(self, request): if images: results.append({"Name": distribution.base_path, "Images": images}) - return Response(data={"Registry": settings.CONTENT_ORIGIN, "Results": results}) + return Response(data={"Registry": request.get_host(), "Results": results}) class FlatpakIndexStaticView(FlatpakIndexDynamicView): @@ -973,13 +1008,33 @@ def handle_safe_method(self, request, path, pk): try: tag = models.Tag.objects.get(name=pk, pk__in=repository_version.content) except models.Tag.DoesNotExist: - raise ManifestNotFound(reference=pk) + if distribution.remote: + remote = distribution.remote.cast() + repository = distribution.repository.cast() + manifest, response = self.get_manifest_from_local_storage(remote, pk) + if not manifest: + if response.status_code == 200: + return redirects.redirect_to_content_app("manifests", pk) + else: + raise ManifestNotFound(reference=pk) + + tag = models.Tag(name=pk, tagged_manifest=manifest) + try: + tag.save() + except IntegrityError: + tag = models.Tag.objects.get(name=tag.name, tagged_manifest=manifest) + tag.touch() + + return redirects.redirect_to_content_app("manifests", tag.name) + else: + raise ManifestNotFound(reference=pk) return redirects.issue_tag_redirect(tag) else: try: manifest = models.Manifest.objects.get(digest=pk, pk__in=repository_version.content) - except models.Manifest.DoesNotExit: + except models.Manifest.DoesNotExist: + repository = repository.cast() if repository.PUSH_ENABLED: # the manifest might be a part of listed manifests currently being uploaded try: @@ -988,10 +1043,37 @@ def handle_safe_method(self, request, path, pk): except models.Manifest.DoesNotExist: raise ManifestNotFound(reference=pk) else: - ManifestNotFound(reference=pk) + if distribution.remote: + remote = distribution.remote.cast() + manifest, response = self.get_manifest_from_local_storage(remote, pk) + if not manifest: + if response.status_code == 200: + return redirects.redirect_to_content_app("manifests", pk) + + raise ManifestNotFound(reference=pk) + else: + ManifestNotFound(reference=pk) return redirects.issue_manifest_redirect(manifest) + def get_manifest_from_local_storage(self, remote, pk): + relative_url = "/v2/{name}/manifests/{tag}".format( + name=remote.namespaced_upstream_name, tag=pk + ) + tag_url = urljoin(remote.url, relative_url) + downloader = remote.get_in_memory_downloader(url=tag_url) + try: + response = downloader.fetch( + extra_data={"headers": V2_ACCEPT_HEADERS, "http_method": "head"} + ) + except ClientResponseError: + # TODO: handle a temporary outage by returning a manifest that was downloaded before; + # if the manifest does not exist locally, then raise ManifestNotFound + raise ManifestNotFound(reference=pk) + + digest = response.headers.get("docker-content-digest") + return models.Manifest.objects.filter(digest=digest).first(), response + def put(self, request, path, pk=None): """ Responds with the actual manifest @@ -1234,7 +1316,15 @@ def get(self, request, path, pk): try: manifest = models.Manifest.objects.get(digest=pk, pk__in=repository_version.content) except models.Manifest.DoesNotExist: - raise ManifestNotFound(reference=pk) + try: + repository = repository_version.repository.cast() + manifest = models.Manifest.objects.get( + digest=pk, pk__in=repository.pending_manifests.values_list("pk") + ) + # TODO: consider adding manifest.touch() and restructuring query to access + # directly repository.pending_manifests.get(digest=pk) + except models.Manifest.DoesNotExist: + raise ManifestNotFound(reference=pk) signatures = models.ManifestSignature.objects.filter( signed_manifest=manifest, pk__in=repository_version.content diff --git a/pulp_container/app/serializers.py b/pulp_container/app/serializers.py index 87393a713..64a1b3bba 100644 --- a/pulp_container/app/serializers.py +++ b/pulp_container/app/serializers.py @@ -277,6 +277,22 @@ class Meta: model = models.ContainerRemote +class ContainerPullThroughRemoteSerializer(RemoteSerializer): + """ + TODO: Customize help messages for url, credentials. + """ + + policy = serializers.ChoiceField( + help_text="The policy always mimics the on_demand behaviour when performing pull-through.", + choices=((models.Remote.ON_DEMAND, "When syncing, download just the metadata.")), + default=models.Remote.ON_DEMAND, + ) + + class Meta: + fields = RemoteSerializer.Meta.fields + model = models.ContainerPullThroughRemote + + class ContainerDistributionSerializer(DistributionSerializer): """ A serializer for ContainerDistribution. @@ -309,10 +325,16 @@ class ContainerDistributionSerializer(DistributionSerializer): repository_version = RepositoryVersionRelatedField( required=False, help_text=_("RepositoryVersion to be served"), allow_null=True ) + remote = DetailRelatedField( + required=False, + help_text=_("Remote that can be used to fetch content when using pull-through caching."), + view_name_pattern=r"remotes(-.*/.*)?-detail", + queryset=models.ContainerRemote.objects.all(), + ) def validate(self, data): """ - Validate the ContainterDistribution. + Validate the ContainerDistribution. Make sure there is an instance of ContentRedirectContentGuard always present in validated data. @@ -360,12 +382,36 @@ class Meta: fields = tuple(set(DistributionSerializer.Meta.fields) - {"base_url"}) + ( "repository_version", "registry_path", + "remote", "namespace", "private", "description", ) +class ContainerPullThroughDistributionSerializer(DistributionSerializer): + """ + TODO: Customize help messages for base paths, names (e.g., dockerhub-cache). + """ + + remote = DetailRelatedField( + help_text=_("Remote that can be used to fetch content when using pull-through caching."), + view_name_pattern=r"remotes(-.*/.*)-detail", + queryset=models.ContainerPullThroughRemote.objects.all(), + ) + distributions = DetailRelatedField( + many=True, + help_text="Distributions created after pulling content through cache", + view_name="distributions-detail", + queryset=models.ContainerDistribution.objects.all(), + required=False, + ) + + class Meta: + model = models.ContainerPullThroughDistribution + fields = DistributionSerializer.Meta.fields + ("remote", "distributions") + + class TagOperationSerializer(ValidateFieldsMixin, serializers.Serializer): """ A base serializer for tagging and untagging manifests. diff --git a/pulp_container/app/tasks/__init__.py b/pulp_container/app/tasks/__init__.py index 6e4392924..09f335a3d 100644 --- a/pulp_container/app/tasks/__init__.py +++ b/pulp_container/app/tasks/__init__.py @@ -1,3 +1,4 @@ +from .download_image_data import download_image_data # noqa from .builder import build_image_from_containerfile # noqa from .recursive_add import recursive_add_content # noqa from .recursive_remove import recursive_remove_content # noqa diff --git a/pulp_container/app/tasks/download_image_data.py b/pulp_container/app/tasks/download_image_data.py new file mode 100644 index 000000000..5e562447e --- /dev/null +++ b/pulp_container/app/tasks/download_image_data.py @@ -0,0 +1,372 @@ +import asyncio +import json +import logging + +from tempfile import NamedTemporaryFile +from urllib.parse import urljoin + +from asgiref.sync import sync_to_async + +from django.db import IntegrityError + +from pulpcore.plugin.models import Artifact +from pulpcore.plugin.stages import ( + ArtifactDownloader, + ArtifactSaver, + DeclarativeArtifact, + DeclarativeContent, + DeclarativeVersion, + RemoteArtifactSaver, + ResolveContentFutures, + Stage, + QueryExistingArtifacts, + QueryExistingContents, +) + +from pulp_container.app.models import Blob, ContainerRemote, ContainerRepository, Manifest, Tag +from pulp_container.app.utils import ( + _save_artifact, + determine_media_type, + determine_media_type_from_json, + validate_manifest, + calculate_digest, +) +from pulp_container.constants import MEDIA_TYPE, V2_ACCEPT_HEADERS + +from .sync_stages import ContainerContentSaver + +log = logging.getLogger(__name__) + + +def download_image_data(repository_pk, remote_pk, tag_name, response_data): + repository = ContainerRepository.objects.get(pk=repository_pk) + remote = ContainerRemote.objects.get(pk=remote_pk).cast() + first_stage = ContainerPullThroughFirstStage(remote, tag_name, response_data) + dv = ContainerPullThroughCacheDeclarativeVersion(first_stage, repository, mirror=False) + return dv.create() + + +class ContainerPullThroughFirstStage(Stage): + """TODO: remove copy-pasted code.""" + + def __init__(self, remote, tag_name, response_data): + """TODO""" + self.remote = remote + self.tag_name = tag_name + self.response_data = response_data + + self.manifest_dcs = [] + self.manifest_list_dcs = [] + + async def run(self): + tag_dc = DeclarativeContent(Tag(name=self.tag_name)) + + content_data = json.loads(self.response_data) + with NamedTemporaryFile("w") as temp_file: + temp_file.write(self.response_data) + temp_file.flush() + + artifact = Artifact.init_and_validate(temp_file.name) + try: + await artifact.asave() + except IntegrityError: + artifact = await Artifact.objects.aget(sha256=artifact.sha256) + await sync_to_async(artifact.touch)() + + media_type = determine_media_type_from_json(content_data) + if media_type in (MEDIA_TYPE.MANIFEST_LIST, MEDIA_TYPE.INDEX_OCI): + list_dc = self.create_tagged_manifest_list( + self.tag_name, artifact, content_data, media_type + ) + for listed_manifest_task in asyncio.as_completed( + [ + self.create_listed_manifest(manifest_data) + for manifest_data in content_data.get("manifests") + ] + ): + listed_manifest = await listed_manifest_task + man_dc = listed_manifest["manifest_dc"] + list_dc.extra_data["listed_manifests"].append(listed_manifest) + else: + tag_dc.extra_data["tagged_manifest_dc"] = list_dc + for listed_manifest in list_dc.extra_data["listed_manifests"]: + await self.handle_blobs( + listed_manifest["manifest_dc"], listed_manifest["content_data"] + ) + self.manifest_dcs.append(listed_manifest["manifest_dc"]) + self.manifest_list_dcs.append(list_dc) + else: + # Simple tagged manifest + man_dc = self.create_tagged_manifest( + self.tag_name, artifact, content_data, self.response_data, media_type + ) + tag_dc.extra_data["tagged_manifest_dc"] = man_dc + await self.handle_blobs(man_dc, content_data) + self.manifest_dcs.append(man_dc) + + for manifest_dc in self.manifest_dcs: + config_blob_dc = manifest_dc.extra_data.get("config_blob_dc") + if config_blob_dc: + manifest_dc.content.config_blob = await config_blob_dc.resolution() + for blob_dc in manifest_dc.extra_data["blob_dcs"]: + # Just await here. They will be associated in the post_save hook. + await blob_dc.resolution() + await self.put(manifest_dc) + self.manifest_dcs.clear() + + for manifest_list_dc in self.manifest_list_dcs: + for listed_manifest in manifest_list_dc.extra_data["listed_manifests"]: + # Just await here. They will be associated in the post_save hook. + await listed_manifest["manifest_dc"].resolution() + await self.put(manifest_list_dc) + self.manifest_list_dcs.clear() + + tagged_manifest_dc = tag_dc.extra_data["tagged_manifest_dc"] + tag_dc.content.tagged_manifest = await tagged_manifest_dc.resolution() + await self.put(tag_dc) + + def create_tagged_manifest_list(self, tag_name, saved_artifact, manifest_list_data, media_type): + """ + Create a ManifestList. + + Args: + tag_name (str): A name of a tag + saved_artifact (pulpcore.plugin.models.Artifact): A saved manifest's Artifact + manifest_list_data (dict): Data about a ManifestList + media_type (str): The type of manifest + + """ + digest = f"sha256:{saved_artifact.sha256}" + manifest_list = Manifest( + digest=digest, schema_version=manifest_list_data["schemaVersion"], media_type=media_type + ) + + manifest_list_dc = self._create_manifest_declarative_content( + manifest_list, saved_artifact, tag_name, digest + ) + manifest_list_dc.extra_data["listed_manifests"] = [] + return manifest_list_dc + + async def create_listed_manifest(self, manifest_data): + """ + Create an Image Manifest from manifest data in a ManifestList. + + Args: + manifest_data (dict): Data about a single new ImageManifest. + + """ + digest = manifest_data["digest"] + relative_url = "/v2/{name}/manifests/{digest}".format( + name=self.remote.namespaced_upstream_name, digest=digest + ) + manifest_url = urljoin(self.remote.url, relative_url) + + if ( + manifest := await Manifest.objects.prefetch_related("contentartifact_set") + .filter(digest=digest) + .afirst() + ): + saved_artifact = await manifest._artifacts.aget() + content_data, _ = await sync_to_async(self._get_content_data_blocking)(saved_artifact) + + else: + saved_artifact, content_data, _, response = await self._download_and_save_artifact_data( + manifest_url + ) + media_type = determine_media_type(content_data, response) + validate_manifest(content_data, media_type, digest) + + manifest = Manifest( + digest=digest, + schema_version=2 + if manifest_data["mediaType"] in (MEDIA_TYPE.MANIFEST_V2, MEDIA_TYPE.MANIFEST_OCI) + else 1, + media_type=manifest_data["mediaType"], + ) + + da = DeclarativeArtifact( + artifact=saved_artifact, + url=manifest_url, + relative_path=digest, + remote=self.remote, + extra_data={"headers": V2_ACCEPT_HEADERS}, + ) + platform = {} + p = manifest_data["platform"] + platform["architecture"] = p["architecture"] + platform["os"] = p["os"] + platform["features"] = p.get("features", "") + platform["variant"] = p.get("variant", "") + platform["os.version"] = p.get("os.version", "") + platform["os.features"] = p.get("os.features", "") + man_dc = DeclarativeContent( + content=manifest, + d_artifacts=[da], + ) + return {"manifest_dc": man_dc, "platform": platform, "content_data": content_data} + + def create_tagged_manifest(self, tag_name, saved_artifact, manifest_data, raw_data, media_type): + """ + Create an Image Manifest. + + Args: + tag_name (str): A name of a tag + saved_artifact (pulpcore.plugin.models.Artifact): A saved manifest's Artifact + manifest_data (dict): Data about a single new ImageManifest. + raw_data: (str): The raw JSON representation of the ImageManifest. + media_type (str): The type of a manifest + + """ + if media_type in (MEDIA_TYPE.MANIFEST_V2, MEDIA_TYPE.MANIFEST_OCI): + digest = f"sha256:{saved_artifact.sha256}" + else: + digest = calculate_digest(raw_data) + + manifest = Manifest( + digest=digest, schema_version=manifest_data["schemaVersion"], media_type=media_type + ) + + return self._create_manifest_declarative_content(manifest, saved_artifact, tag_name, digest) + + def _create_manifest_declarative_content(self, manifest, saved_artifact, tag_name, digest): + relative_url = f"/v2/{self.remote.namespaced_upstream_name}/manifests/" + da_digest = self._create_manifest_declarative_artifact( + relative_url + digest, saved_artifact, digest + ) + da_tag = self._create_manifest_declarative_artifact( + relative_url + tag_name, saved_artifact, digest + ) + + man_dc = DeclarativeContent(content=manifest, d_artifacts=[da_digest, da_tag]) + return man_dc + + def _create_manifest_declarative_artifact(self, relative_url, saved_artifact, digest): + url = urljoin(self.remote.url, relative_url) + da = DeclarativeArtifact( + artifact=saved_artifact, + url=url, + relative_path=digest, + remote=self.remote, + extra_data={"headers": V2_ACCEPT_HEADERS}, + ) + return da + + def _get_content_data_blocking(self, saved_artifact): + raw_data = saved_artifact.file.read() + content_data = json.loads(raw_data) + saved_artifact.file.close() + return content_data, raw_data + + async def _download_and_save_artifact_data(self, manifest_url): + downloader = self.remote.get_downloader(url=manifest_url) + response = await downloader.run(extra_data={"headers": V2_ACCEPT_HEADERS}) + with open(response.path, "rb") as content_file: + raw_data = content_file.read() + response.artifact_attributes["file"] = response.path + + saved_artifact = await _save_artifact(response.artifact_attributes) + content_data = json.loads(raw_data) + + return saved_artifact, content_data, raw_data, response + + async def handle_blobs(self, manifest_dc, content_data): + """ + Handle blobs. + """ + manifest_dc.extra_data["blob_dcs"] = [] + for layer in content_data.get("layers") or content_data.get("fsLayers"): + if not self._include_layer(layer): + continue + blob_dc = self.create_blob(layer) + manifest_dc.extra_data["blob_dcs"].append(blob_dc) + await self.put(blob_dc) + layer = content_data.get("config", None) + if layer: + blob_dc = self.create_blob(layer, deferred_download=False) + manifest_dc.extra_data["config_blob_dc"] = blob_dc + await self.put(blob_dc) + + def _include_layer(self, layer): + """ + Decide whether to include a layer. + + Args: + layer (dict): Layer reference. + + Returns: + bool: True when the layer should be included. + + """ + foreign_excluded = not self.remote.include_foreign_layers + layer_type = layer.get("mediaType", MEDIA_TYPE.REGULAR_BLOB) + is_foreign = layer_type in ( + MEDIA_TYPE.FOREIGN_BLOB, + MEDIA_TYPE.FOREIGN_BLOB_OCI_TAR, + MEDIA_TYPE.FOREIGN_BLOB_OCI_TAR_GZIP, + MEDIA_TYPE.FOREIGN_BLOB_OCI_TAR_ZSTD, + ) + if is_foreign and foreign_excluded: + log.debug("Foreign Layer: %(d)s EXCLUDED", dict(d=layer)) + return False + return True + + def create_blob(self, blob_data, deferred_download=True): + """ + Create blob. + + Args: + blob_data (dict): Data about a blob + deferred_download (bool): boolean that indicates whether not to download a blob + immediatly. Config blob is downloaded regardless of the remote's settings + + """ + digest = blob_data.get("digest") or blob_data.get("blobSum") + blob_artifact = Artifact(sha256=digest[len("sha256:") :]) + blob = Blob(digest=digest) + relative_url = "/v2/{name}/blobs/{digest}".format( + name=self.remote.namespaced_upstream_name, digest=digest + ) + blob_url = urljoin(self.remote.url, relative_url) + da = DeclarativeArtifact( + artifact=blob_artifact, + url=blob_url, + relative_path=digest, + remote=self.remote, + deferred_download=deferred_download, + ) + blob_dc = DeclarativeContent(content=blob, d_artifacts=[da]) + + return blob_dc + + +class ContainerPullThroughCacheDeclarativeVersion(DeclarativeVersion): + """ + Subclassed Declarative version creates a custom pipeline for Container sync. + """ + + def pipeline_stages(self, new_version): + """ + Build a list of stages feeding into the ContentUnitAssociation stage. + + This defines the "architecture" of the entire sync. + + Args: + new_version (:class:`~pulpcore.plugin.models.RepositoryVersion`): The + new repository version that is going to be built. + + Returns: + list: List of :class:`~pulpcore.plugin.stages.Stage` instances + + """ + pipeline = [ + self.first_stage, + QueryExistingArtifacts(), + ArtifactDownloader(), + ArtifactSaver(), + QueryExistingContents(), + ContainerContentSaver(), + RemoteArtifactSaver(), + ResolveContentFutures(), + ] + + return pipeline diff --git a/pulp_container/app/tasks/sync_stages.py b/pulp_container/app/tasks/sync_stages.py index 3c1a94413..7451a0460 100644 --- a/pulp_container/app/tasks/sync_stages.py +++ b/pulp_container/app/tasks/sync_stages.py @@ -9,17 +9,16 @@ from urllib.parse import urljoin, urlparse, urlunparse from asgiref.sync import sync_to_async -from django.db import IntegrityError from pulpcore.plugin.models import Artifact, ProgressReport, Remote from pulpcore.plugin.stages import DeclarativeArtifact, DeclarativeContent, Stage, ContentSaver from pulp_container.constants import ( - V2_ACCEPT_HEADERS, MEDIA_TYPE, SIGNATURE_API_EXTENSION_VERSION, SIGNATURE_HEADER, SIGNATURE_SOURCE, SIGNATURE_TYPE, + V2_ACCEPT_HEADERS, ) from pulp_container.app.models import ( Blob, @@ -30,26 +29,17 @@ Tag, ) from pulp_container.app.utils import ( + _save_artifact, extract_data_from_signature, urlpath_sanitize, determine_media_type, validate_manifest, + calculate_digest, ) log = logging.getLogger(__name__) -async def _save_artifact(artifact_attributes): - saved_artifact = Artifact(**artifact_attributes) - try: - await saved_artifact.asave() - except IntegrityError: - del artifact_attributes["file"] - saved_artifact = await Artifact.objects.aget(**artifact_attributes) - await sync_to_async(saved_artifact.touch)() - return saved_artifact - - class ContainerFirstStage(Stage): """ The first stage of a pulp_container sync pipeline. @@ -382,7 +372,7 @@ def create_tagged_manifest_list(self, tag_name, saved_artifact, manifest_list_da tag_name (str): A name of a tag saved_artifact (pulpcore.plugin.models.Artifact): A saved manifest's Artifact manifest_list_data (dict): Data about a ManifestList - media_type (str): The type of a manifest + media_type (str): The type of manifest """ digest = f"sha256:{saved_artifact.sha256}" @@ -411,7 +401,7 @@ def create_tagged_manifest(self, tag_name, saved_artifact, manifest_data, raw_da if media_type in (MEDIA_TYPE.MANIFEST_V2, MEDIA_TYPE.MANIFEST_OCI): digest = f"sha256:{saved_artifact.sha256}" else: - digest = self._calculate_digest(raw_data) + digest = calculate_digest(raw_data) manifest = Manifest( digest=digest, schema_version=manifest_data["schemaVersion"], media_type=media_type @@ -649,77 +639,6 @@ def _include_layer(self, layer): return False return True - def _calculate_digest(self, manifest): - """ - Calculate the requested digest of the ImageManifest, given in JSON. - - Args: - manifest (str): The raw JSON representation of the Manifest. - - Returns: - str: The digest of the given ImageManifest - - """ - decoded_manifest = json.loads(manifest) - if "signatures" in decoded_manifest: - # This manifest contains signatures. Unfortunately, the Docker manifest digest - # is calculated on the unsigned version of the Manifest so we need to remove the - # signatures. To do this, we will look at the 'protected' key within the first - # signature. This key indexes a (malformed) base64 encoded JSON dictionary that - # tells us how many bytes of the manifest we need to keep before the signature - # appears in the original JSON and what the original ending to the manifest was after - # the signature block. We will strip out the bytes after this cutoff point, add back the - # original ending, and then calculate the sha256 sum of the transformed JSON to get the - # digest. - protected = decoded_manifest["signatures"][0]["protected"] - # Add back the missing padding to the protected block so that it is valid base64. - protected = self._pad_unpadded_b64(protected) - # Now let's decode the base64 and load it as a dictionary so we can get the length - protected = base64.b64decode(protected) - protected = json.loads(protected) - # This is the length of the signed portion of the Manifest, except for a trailing - # newline and closing curly brace. - signed_length = protected["formatLength"] - # The formatTail key indexes a base64 encoded string that represents the end of the - # original Manifest before signatures. We will need to add this string back to the - # trimmed Manifest to get the correct digest. We'll do this as a one liner since it is - # a very similar process to what we've just done above to get the protected block - # decoded. - signed_tail = base64.b64decode(self._pad_unpadded_b64(protected["formatTail"])) - # Now we can reconstruct the original Manifest that the digest should be based on. - manifest = manifest[:signed_length] + signed_tail - - return "sha256:{digest}".format(digest=hashlib.sha256(manifest).hexdigest()) - - def _pad_unpadded_b64(self, unpadded_b64): - """ - Fix bad padding. - - Docker has not included the required padding at the end of the base64 encoded - 'protected' block, or in some encased base64 within it. This function adds the correct - number of ='s signs to the unpadded base64 text so that it can be decoded with Python's - base64 library. - - Args: - unpadded_b64 (str): The unpadded base64 text. - - Returns: - str: The same base64 text with the appropriate number of ='s symbols. - - """ - # The Pulp team has not observed any newlines or spaces within the base64 from Docker, but - # Docker's own code does this same operation so it seemed prudent to include it here. - # See lines 167 to 168 here: - # https://github.com/docker/libtrust/blob/9cbd2a1374f46905c68a4eb3694a130610adc62a/util.go - unpadded_b64 = unpadded_b64.replace("\n", "").replace(" ", "") - # It is illegal base64 for the remainder to be 1 when the length of the block is - # divided by 4. - if len(unpadded_b64) % 4 == 1: - raise ValueError("Invalid base64: {t}".format(t=unpadded_b64)) - # Add back the missing padding characters, based on the length of the encoded string - paddings = {0: "", 2: "==", 3: "="} - return unpadded_b64 + paddings[len(unpadded_b64) % 4] - class ContainerContentSaver(ContentSaver): """Container specific content saver stage to add content associations.""" diff --git a/pulp_container/app/utils.py b/pulp_container/app/utils.py index fe6c6e71a..cf88ac0b1 100644 --- a/pulp_container/app/utils.py +++ b/pulp_container/app/utils.py @@ -1,3 +1,5 @@ +import base64 +import hashlib import re import subprocess import gnupg @@ -8,7 +10,10 @@ from jsonschema import Draft7Validator, validate, ValidationError from rest_framework.exceptions import Throttled -from pulpcore.plugin.models import Task +from asgiref.sync import sync_to_async +from django.db import IntegrityError + +from pulpcore.plugin.models import Task, Artifact from pulp_container.constants import ALLOWED_ARTIFACT_TYPES, MANIFEST_MEDIA_TYPES, MEDIA_TYPE from pulp_container.app.exceptions import ManifestInvalid @@ -213,3 +218,87 @@ def validate_manifest(content_data, media_type, digest): raise ManifestInvalid( reason=f'{".".join(map(str, error.path))}: {error.message}', digest=digest ) + + +def calculate_digest(manifest): + """ + Calculate the requested digest of the ImageManifest, given in JSON. + + Args: + manifest (str): The raw JSON representation of the Manifest. + + Returns: + str: The digest of the given ImageManifest + + """ + decoded_manifest = json.loads(manifest) + if "signatures" in decoded_manifest: + # This manifest contains signatures. Unfortunately, the Docker manifest digest + # is calculated on the unsigned version of the Manifest so we need to remove the + # signatures. To do this, we will look at the 'protected' key within the first + # signature. This key indexes a (malformed) base64 encoded JSON dictionary that + # tells us how many bytes of the manifest we need to keep before the signature + # appears in the original JSON and what the original ending to the manifest was after + # the signature block. We will strip out the bytes after this cutoff point, add back the + # original ending, and then calculate the sha256 sum of the transformed JSON to get the + # digest. + protected = decoded_manifest["signatures"][0]["protected"] + # Add back the missing padding to the protected block so that it is valid base64. + protected = pad_unpadded_b64(protected) + # Now let's decode the base64 and load it as a dictionary so we can get the length + protected = base64.b64decode(protected) + protected = json.loads(protected) + # This is the length of the signed portion of the Manifest, except for a trailing + # newline and closing curly brace. + signed_length = protected["formatLength"] + # The formatTail key indexes a base64 encoded string that represents the end of the + # original Manifest before signatures. We will need to add this string back to the + # trimmed Manifest to get the correct digest. We'll do this as a one liner since it is + # a very similar process to what we've just done above to get the protected block + # decoded. + signed_tail = base64.b64decode(pad_unpadded_b64(protected["formatTail"])) + # Now we can reconstruct the original Manifest that the digest should be based on. + manifest = manifest[:signed_length] + signed_tail + + return "sha256:{digest}".format(digest=hashlib.sha256(manifest).hexdigest()) + + +def pad_unpadded_b64(unpadded_b64): + """ + Fix bad padding. + + Docker has not included the required padding at the end of the base64 encoded + 'protected' block, or in some encased base64 within it. This function adds the correct + number of ='s signs to the unpadded base64 text so that it can be decoded with Python's + base64 library. + + Args: + unpadded_b64 (str): The unpadded base64 text. + + Returns: + str: The same base64 text with the appropriate number of ='s symbols. + + """ + # The Pulp team has not observed any newlines or spaces within the base64 from Docker, but + # Docker's own code does this same operation so it seemed prudent to include it here. + # See lines 167 to 168 here: + # https://github.com/docker/libtrust/blob/9cbd2a1374f46905c68a4eb3694a130610adc62a/util.go + unpadded_b64 = unpadded_b64.replace("\n", "").replace(" ", "") + # It is illegal base64 for the remainder to be 1 when the length of the block is + # divided by 4. + if len(unpadded_b64) % 4 == 1: + raise ValueError("Invalid base64: {t}".format(t=unpadded_b64)) + # Add back the missing padding characters, based on the length of the encoded string + paddings = {0: "", 2: "==", 3: "="} + return unpadded_b64 + paddings[len(unpadded_b64) % 4] + + +async def _save_artifact(artifact_attributes): + saved_artifact = Artifact(**artifact_attributes) + try: + await saved_artifact.asave() + except IntegrityError: + del artifact_attributes["file"] + saved_artifact = await Artifact.objects.aget(**artifact_attributes) + await sync_to_async(saved_artifact.touch)() + return saved_artifact diff --git a/pulp_container/app/viewsets.py b/pulp_container/app/viewsets.py index 4a4501a1f..535e147eb 100644 --- a/pulp_container/app/viewsets.py +++ b/pulp_container/app/viewsets.py @@ -429,6 +429,86 @@ class ContainerRemoteViewSet(RemoteViewSet, RolesMixin): } +class ContainerPullThroughRemoteViewSet(RemoteViewSet, RolesMixin): + """ + A Container Remote referencing a remote registry used as a source for the pull-through caching. + """ + + endpoint_name = "pull-through" + queryset = models.ContainerPullThroughRemote.objects.all() + serializer_class = serializers.ContainerPullThroughRemoteSerializer + queryset_filtering_required_permission = "container.view_containerpullthroughremote" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_perms:container.add_containerpullthroughremote", + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_obj_perms:container.view_containerpullthroughremote", + }, + { + "action": ["update", "partial_update"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:container.change_containerpullthroughremote", + "has_model_or_obj_perms:container.view_containerpullthroughremote", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:container.delete_containerpullthroughremote", + "has_model_or_obj_perms:container.view_containerpullthroughremote", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:container.manage_roles_containerpullthroughremote" + ], + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "container.containerpullthroughremote_owner"}, + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "container.containerpullthroughremote_creator": [ + "container.add_containerpullthroughremote", + ], + "container.containerpullthroughremote_owner": [ + "container.view_containerpullthroughremote", + "container.change_containerpullthroughremote", + "container.delete_containerpullthroughremote", + "container.manage_roles_containerpullthroughremote", + ], + "container.containerpullthroughremote_viewer": [ + "container.view_containerpullthroughremote", + ], + } + + class TagOperationsMixin: """ A mixin that adds functionality for creating and deleting tags. @@ -1302,6 +1382,103 @@ def destroy(self, request, pk, **kwargs): return OperationPostponedResponse(async_result, request) +class ContainerPullThroughDistributionViewSet(DistributionViewSet, RolesMixin): + """ + A special pull-through Container Distribution that will reference distributions serving content. + """ + + endpoint_name = "pull-through" + queryset = models.ContainerPullThroughDistribution.objects.all() + serializer_class = serializers.ContainerPullThroughDistributionSerializer + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_namespace_model_perms", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_namespace_perms:container.add_containerpullthroughdistribution", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "namespace_is_username", + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition_expression": [ + "has_namespace_or_obj_perms:container.view_containerpullthroughdistribution", + ], + }, + { + "action": ["update", "partial_update"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_namespace_or_obj_perms:container.change_containerpullthroughdistribution", + "has_namespace_or_obj_perms:container.view_containerpullthroughdistribution", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_namespace_or_obj_perms:container.delete_containerpullthroughdistribution", + "has_namespace_or_obj_perms:container.view_containerpullthroughdistribution", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:container.manage_roles_containerpullthroughdistribution" + ], + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": { + "roles": "container.containerpullthroughdistribution_owner", + }, + }, + ], + } + LOCKED_ROLES = { + "container.containerpullthroughdistribution_creator": [ + "container.add_containerpullthroughdistribution" + ], + "container.containerpullthroughdistribution_owner": [ + "container.view_containerpullthroughdistribution", + "container.delete_containerpullthroughdistribution", + "container.change_containerpullthroughdistribution", + "container.manage_roles_containerpullthroughdistribution", + ], + "container.containerpullthroughdistribution_collaborator": [ + "container.view_containerpullthroughdistribution", + ], + "container.containerpullthroughdistribution_consumer": [ + "container.view_containerpullthroughdistribution", + ], + } + + class ContainerNamespaceViewSet( NamedModelViewSet, mixins.CreateModelMixin, diff --git a/pulp_container/tests/functional/api/test_pull_through_cache.py b/pulp_container/tests/functional/api/test_pull_through_cache.py new file mode 100644 index 000000000..ff27ae940 --- /dev/null +++ b/pulp_container/tests/functional/api/test_pull_through_cache.py @@ -0,0 +1,45 @@ +from uuid import uuid4 + + +def test_consume_content( + delete_orphans_pre, + add_to_cleanup, + gen_object_with_cleanup, + container_pull_through_remote_api, + container_pull_through_distribution_api, + registry_client, + local_registry, + container_repository_api, + container_remote_api, + container_distribution_api, +): + data = {"name": str(uuid4()), "url": "https://registry-1.docker.io"} + remote = gen_object_with_cleanup(container_pull_through_remote_api, data) + + pull_through_path = str(uuid4()) + data = {"name": str(uuid4()), "base_path": pull_through_path, "remote": remote.pulp_href} + distribution = gen_object_with_cleanup(container_pull_through_distribution_api, data) + + remote_image_path = "library/busybox" + + registry_client.pull(f"docker.io/{remote_image_path}:latest") + remote_image = registry_client.inspect(f"docker.io/{remote_image_path}") + + local_registry.pull(f"{distribution.base_path}/{remote_image_path}") + + # clean up a newly created repository, distribution, and remote + path = f"{pull_through_path}/{remote_image_path}" + repositories = container_repository_api.list(name=path).results + add_to_cleanup(container_repository_api, repositories[0].pulp_href) + remotes = container_distribution_api.list(name=path).results + add_to_cleanup(container_remote_api, remotes[0].pulp_href) + distributions = container_remote_api.list(name=path).results + add_to_cleanup(container_distribution_api, distributions[0].pulp_href) + + local_image = local_registry.inspect(f"{distribution.base_path}/{remote_image_path}") + + assert local_image[0]["Id"] == remote_image[0]["Id"] + + assert 1 == len(repositories) + assert 1 == len(remotes) + assert 1 == len(distributions) diff --git a/pulp_container/tests/functional/conftest.py b/pulp_container/tests/functional/conftest.py index f12032388..db9bbab2e 100644 --- a/pulp_container/tests/functional/conftest.py +++ b/pulp_container/tests/functional/conftest.py @@ -13,11 +13,13 @@ ApiClient, PulpContainerNamespacesApi, RemotesContainerApi, + RemotesPullThroughApi, RepositoriesContainerApi, RepositoriesContainerPushApi, RepositoriesContainerVersionsApi, RepositoriesContainerPushVersionsApi, DistributionsContainerApi, + DistributionsPullThroughApi, ContentTagsApi, ContentManifestsApi, ContentBlobsApi, @@ -317,6 +319,12 @@ def container_remote_api(container_client): return RemotesContainerApi(container_client) +@pytest.fixture(scope="session") +def container_pull_through_remote_api(container_client): + """Pull through cache container remote API fixture.""" + return RemotesPullThroughApi(container_client) + + @pytest.fixture(scope="session") def container_repository_api(container_client): """Container repository API fixture.""" @@ -347,6 +355,12 @@ def container_distribution_api(container_client): return DistributionsContainerApi(container_client) +@pytest.fixture(scope="session") +def container_pull_through_distribution_api(container_client): + """Pull through cache distribution API Fixture.""" + return DistributionsPullThroughApi(container_client) + + @pytest.fixture(scope="session") def container_tag_api(container_client): """Container tag API fixture.""" diff --git a/requirements.txt b/requirements.txt index 4262f9327..d5385d19e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ ecdsa>=0.14,<=0.18.0 jsonschema>=4.4,<4.20 -pulpcore>=3.25.0,<3.40 +pulpcore>=3.30.0,<3.40 pyjwkest>=1.4,<=1.4.2 pyjwt[crypto]>=2.4,<2.9