Skip to content

Commit

Permalink
Add support for tagging and untagging for push repositories
Browse files Browse the repository at this point in the history
Note that in this commit, there are not handled race conditions where a container client simulateneously tries to push a tag that is being created in an asynchronous task invoked via the Pulp API.

closes #8104
  • Loading branch information
lubosmj authored and ipanova committed Jan 27, 2021
1 parent c2577e9 commit 29ff305
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 87 deletions.
1 change: 1 addition & 0 deletions CHANGES/8104.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for tagging and untagging manifests for push repositories.
4 changes: 0 additions & 4 deletions docs/workflows/manage-content.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ There are multiple ways that users can manage Container content in repositories:
2. Recursively :ref:`add<recursive-add>` or :ref:`remove<recursive-remove>` Container content.
3. Copy :ref:`tags<tag-copy>` or :ref:`manifests <manifest-copy>` from source repository.

.. note::
In a push repository it is not possible to perform any tagging, additon or removal of content via the Pulp API.
However, it is possible to use a push repository as a source repository in copy operations.

Each of these workflows kicks off a task, and when the task is complete,
a new repository version will have been created.

Expand Down
6 changes: 3 additions & 3 deletions pulp_container/app/tasks/tag.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pulpcore.plugin.models import ContentArtifact, CreatedResource
from pulp_container.app.models import ContainerRepository, Manifest, Tag
from pulpcore.plugin.models import ContentArtifact, CreatedResource, Repository
from pulp_container.app.models import Manifest, Tag


def tag_image(manifest_pk, tag, repository_pk):
Expand All @@ -15,7 +15,7 @@ def tag_image(manifest_pk, tag, repository_pk):
manifest = Manifest.objects.get(pk=manifest_pk)
artifact = manifest._artifacts.all()[0]

repository = ContainerRepository.objects.get(pk=repository_pk)
repository = Repository.objects.get(pk=repository_pk).cast()
latest_version = repository.latest_version()

tags_to_remove = Tag.objects.filter(pk__in=latest_version.content.all(), name=tag).exclude(
Expand Down
5 changes: 3 additions & 2 deletions pulp_container/app/tasks/untag.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from pulp_container.app.models import ContainerRepository, Tag
from pulpcore.plugin.models import Repository
from pulp_container.app.models import Tag


def untag_image(tag, repository_pk):
"""
Create a new repository version without a specified manifest's tag name.
"""
repository = ContainerRepository.objects.get(pk=repository_pk)
repository = Repository.objects.get(pk=repository_pk).cast()
latest_version = repository.latest_version()

tags_in_latest_repository = latest_version.content.filter(pulp_type="container.tag")
Expand Down
124 changes: 69 additions & 55 deletions pulp_container/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,66 @@ class ContainerRemoteViewSet(RemoteViewSet):
}


class ContainerRepositoryViewSet(RepositoryViewSet):
class TagOperationsMixin:
"""
A mixin that adds functionality for creating and deleting tags.
"""

@extend_schema(
description="Trigger an asynchronous task to tag an image in the repository",
summary="Create a Tag",
responses={202: AsyncOperationResponseSerializer},
request=serializers.TagImageSerializer,
)
@action(detail=True, methods=["post"], serializer_class=serializers.TagImageSerializer)
def tag(self, request, pk):
"""
Create a task which is responsible for creating a new tag.
"""
repository = self.get_object()
request.data["repository"] = repository

serializer = serializers.TagImageSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)

manifest = serializer.validated_data["manifest"]
tag = serializer.validated_data["tag"]

result = enqueue_with_reservation(
tasks.tag_image,
[repository, manifest],
kwargs={"manifest_pk": manifest.pk, "tag": tag, "repository_pk": repository.pk},
)
return OperationPostponedResponse(result, request)

@extend_schema(
description="Trigger an asynchronous task to untag an image in the repository",
summary="Delete a tag",
responses={202: AsyncOperationResponseSerializer},
request=serializers.UnTagImageSerializer,
)
@action(detail=True, methods=["post"], serializer_class=serializers.UnTagImageSerializer)
def untag(self, request, pk):
"""
Create a task which is responsible for untagging an image.
"""
repository = self.get_object()
request.data["repository"] = repository

serializer = serializers.UnTagImageSerializer(
data=request.data, context={"request": request}
)
serializer.is_valid(raise_exception=True)

tag = serializer.validated_data["tag"]

result = enqueue_with_reservation(
tasks.untag_image, [repository], kwargs={"tag": tag, "repository_pk": repository.pk}
)
return OperationPostponedResponse(result, request)


class ContainerRepositoryViewSet(TagOperationsMixin, RepositoryViewSet):
"""
ViewSet for container repo.
"""
Expand Down Expand Up @@ -317,59 +376,6 @@ def sync(self, request, pk):
)
return OperationPostponedResponse(result, request)

@extend_schema(
description="Trigger an asynchronous task to tag an image in the repository",
summary="Create a Tag",
responses={202: AsyncOperationResponseSerializer},
request=serializers.TagImageSerializer,
)
@action(detail=True, methods=["post"], serializer_class=serializers.TagImageSerializer)
def tag(self, request, pk):
"""
Create a task which is responsible for creating a new tag.
"""
repository = self.get_object()
request.data["repository"] = repository

serializer = serializers.TagImageSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)

manifest = serializer.validated_data["manifest"]
tag = serializer.validated_data["tag"]

result = enqueue_with_reservation(
tasks.tag_image,
[repository, manifest],
kwargs={"manifest_pk": manifest.pk, "tag": tag, "repository_pk": repository.pk},
)
return OperationPostponedResponse(result, request)

@extend_schema(
description="Trigger an asynchronous task to untag an image in the repository",
summary="Delete a tag",
responses={202: AsyncOperationResponseSerializer},
request=serializers.UnTagImageSerializer,
)
@action(detail=True, methods=["post"], serializer_class=serializers.UnTagImageSerializer)
def untag(self, request, pk):
"""
Create a task which is responsible for untagging an image.
"""
repository = self.get_object()
request.data["repository"] = repository

serializer = serializers.UnTagImageSerializer(
data=request.data, context={"request": request}
)
serializer.is_valid(raise_exception=True)

tag = serializer.validated_data["tag"]

result = enqueue_with_reservation(
tasks.untag_image, [repository], kwargs={"tag": tag, "repository_pk": repository.pk}
)
return OperationPostponedResponse(result, request)

@extend_schema(
description="Trigger an asynchronous task to recursively add container content.",
summary="Add content",
Expand Down Expand Up @@ -547,7 +553,7 @@ class ContainerRepositoryVersionViewSet(RepositoryVersionViewSet):
parent_viewset = ContainerRepositoryViewSet


class ContainerPushRepositoryViewSet(ReadOnlyRepositoryViewSet):
class ContainerPushRepositoryViewSet(TagOperationsMixin, ReadOnlyRepositoryViewSet):
"""
ViewSet for a container push repository.
Expand Down Expand Up @@ -575,6 +581,14 @@ class ContainerPushRepositoryViewSet(ReadOnlyRepositoryViewSet):
"effect": "allow",
"condition": "has_model_or_obj_perms:container.view_containerpushrepository",
},
{
"action": ["tag", "untag"],
"principal": "authenticated",
"effect": "allow",
"condition": [
"has_model_or_obj_perms:container.modify_content_containerrepository",
],
},
],
"permissions_assignment": [
{
Expand Down
122 changes: 99 additions & 23 deletions pulp_container/tests/functional/api/test_tagging_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"""Tests for tagging and untagging images."""
import unittest

from urllib.parse import urlparse

from pulp_smash import cli, config, exceptions
from pulp_smash.pulp3.bindings import monitor_task
from pulp_smash.pulp3.utils import gen_repo

Expand All @@ -21,6 +24,7 @@
ContentManifestsApi,
ContentTagsApi,
RepositoriesContainerApi,
RepositoriesContainerPushApi,
RepositoriesContainerVersionsApi,
RepositorySyncURL,
RemotesContainerApi,
Expand All @@ -29,8 +33,37 @@
)


class TaggingTestCase(unittest.TestCase):
"""Test case for tagging and untagging images."""
class TaggingTestCommons:
"""Common utilities for tagging and untagging images."""

def get_manifest_by_tag(self, tag_name):
"""Fetch a manifest by the tag name."""
latest_version_href = self.repositories_api.read(
self.repository.pulp_href
).latest_version_href

manifest_href = (
self.tags_api.list(name=tag_name, repository_version=latest_version_href)
.results[0]
.tagged_manifest
)
return self.manifests_api.read(manifest_href)

def tag_image(self, manifest, tag_name):
"""Perform a tagging operation."""
tag_data = TagImage(tag=tag_name, digest=manifest.digest)
tag_response = self.repositories_api.tag(self.repository.pulp_href, tag_data)
monitor_task(tag_response.task)

def untag_image(self, tag_name):
"""Perform an untagging operation."""
untag_data = UnTagImage(tag=tag_name)
untag_response = self.repositories_api.untag(self.repository.pulp_href, untag_data)
monitor_task(untag_response.task)


class RepositoryTaggingTestCase(TaggingTestCommons, unittest.TestCase):
"""A test case for standard a container repository."""

@classmethod
def setUpClass(cls):
Expand Down Expand Up @@ -174,27 +207,70 @@ def test_05_untag_second_image_again(self):
with self.assertRaises(ApiException):
self.untag_image("new_tag")

def get_manifest_by_tag(self, tag_name):
"""Fetch a manifest by the tag name."""
latest_version_href = self.repositories_api.read(
self.repository.pulp_href
).latest_version_href

manifest_a_href = (
self.tags_api.list(name=tag_name, repository_version=latest_version_href)
.results[0]
.tagged_manifest
)
return self.manifests_api.read(manifest_a_href)
class PushRepositoryTaggingTestCase(TaggingTestCommons, unittest.TestCase):
"""A test case for a container push repository."""

def tag_image(self, manifest, tag_name):
"""Perform a tagging operation."""
tag_data = TagImage(tag=tag_name, digest=manifest.digest)
tag_response = self.repositories_api.tag(self.repository.pulp_href, tag_data)
monitor_task(tag_response.task)
@classmethod
def setUpClass(cls):
"""Define APIs to use and pull images needed later in tests."""
api_client = gen_container_client()
cls.tags_api = ContentTagsApi(api_client)
cls.manifests_api = ContentManifestsApi(api_client)
cls.repositories_api = RepositoriesContainerPushApi(api_client)

cfg = config.get_config()
cls.registry = cli.RegistryClient(cfg)
cls.registry.raise_if_unsupported(unittest.SkipTest, "Tests require podman/docker")
cls.registry_name = urlparse(cfg.get_base_url()).netloc

cls.repository_name = "namespace/tags"
cls.registry_repository_name = f"{cls.registry_name}/{cls.repository_name}"
manifest_a = f"{DOCKERHUB_PULP_FIXTURE_1}:manifest_a"
tagged_registry_manifest_a = f"{cls.registry_repository_name}:manifest_a"
manifest_b = f"{DOCKERHUB_PULP_FIXTURE_1}:manifest_b"
tagged_registry_manifest_b = f"{cls.registry_repository_name}:manifest_b"

cls.registry.pull(manifest_a)
cls.registry.pull(manifest_b)
cls.registry.tag(manifest_a, tagged_registry_manifest_a)
cls.registry.tag(manifest_b, tagged_registry_manifest_b)
cls.registry.login("-u", "admin", "-p", "password", cls.registry_name)
cls.registry.push(tagged_registry_manifest_a)
cls.registry.push(tagged_registry_manifest_b)

cls.repository = cls.repositories_api.list(name=cls.repository_name).results[0]

def untag_image(self, tag_name):
"""Perform an untagging operation."""
untag_data = UnTagImage(tag=tag_name)
untag_response = self.repositories_api.untag(self.repository.pulp_href, untag_data)
monitor_task(untag_response.task)
def test_01_tag_first_image(self):
"""Check if a tag was created and correctly pulled from a repository."""
manifest_a = self.get_manifest_by_tag("manifest_a")
self.tag_image(manifest_a, "new_tag")

tagged_image = f"{self.registry_repository_name}:new_tag"
self.registry.pull(tagged_image)
self.registry.rmi(tagged_image)

def test_02_tag_second_image_with_same_tag(self):
"""Check if the existing tag correctly references a new manifest."""
tagged_image = f"{self.registry_repository_name}:manifest_b"
self.registry.pull(tagged_image)
local_image_b = self.registry.inspect(tagged_image)
self.registry.rmi(tagged_image)

manifest_b = self.get_manifest_by_tag("manifest_b")
self.tag_image(manifest_b, "new_tag")
tagged_image = f"{self.registry_repository_name}:new_tag"
self.registry.pull(tagged_image)
local_image_b_tagged = self.registry.inspect(tagged_image)

self.assertEqual(local_image_b[0]["Id"], local_image_b_tagged[0]["Id"])

self.registry.rmi(tagged_image)

def test_03_remove_tag(self):
"""Check if the client cannot pull by the removed tag."""
self.untag_image("new_tag")

non_existing_tagged_image = f"{self.registry_repository_name}:new_tag"
with self.assertRaises(exceptions.CalledProcessError):
self.registry.pull(non_existing_tagged_image)

0 comments on commit 29ff305

Please sign in to comment.