Repository: pulp/pulpcore

Issue:
As a user, I can view orphans that will be cleaned up during orphan cleanup
It would be nice to be able to view orphan content in Pulp before running orphan cleanup. Perhaps two options:

1. An orphan list endpoint that lists orphans that would be cleaned up during orphan cleanup
2. A "dry run" option to orphan cleanup that would list orphans that would be cleaned up during orphan cleanup


Discussion:
Orphan clean up endpoint is opened only to the admin, meaning that only admin would see orphans too.
One potential concern, such an operation would be slow and intensive, possibly too much so for an API call unless it ran periodically and returned cached results.

Resolving patch:
diff --git a/pulpcore/app/models/content.py b/pulpcore/app/models/content.py
--- a/pulpcore/app/models/content.py
+++ b/pulpcore/app/models/content.py
@@ -166,7 +166,7 @@ def delete(self, *args, **kwargs):
         transaction.on_commit(partial(self.file.delete, save=False))
 
 
-class ArtifactManager(BulkCreateManager):
+class ArtifactQuerySet(BulkTouchQuerySet):
     def orphaned(self, orphan_protection_time):
         """Returns set of orphaned artifacts that are ready to be cleaned up."""
         domain_pk = get_domain_pk()
@@ -226,7 +226,7 @@ def storage_path(self, name):
     timestamp_of_interest = models.DateTimeField(auto_now=True)
     pulp_domain = models.ForeignKey("Domain", default=get_domain_pk, on_delete=models.PROTECT)
 
-    objects = ArtifactManager.from_queryset(BulkTouchQuerySet)()
+    objects = BulkCreateManager.from_queryset(ArtifactQuerySet)()
 
     # All available digest fields ordered by algorithm strength.
     DIGEST_FIELDS = _DIGEST_FIELDS
@@ -485,7 +485,7 @@ def init_and_validate(file, expected_digests=None, expected_size=None):
         return PulpTemporaryFile(file=file)
 
 
-class ContentManager(BulkCreateManager):
+class ContentQuerySet(BulkTouchQuerySet):
     def orphaned(self, orphan_protection_time, content_pks=None):
         """Returns set of orphaned content that is ready to be cleaned up."""
         expiration = now() - datetime.timedelta(minutes=orphan_protection_time)
@@ -504,7 +504,7 @@ def orphaned(self, orphan_protection_time, content_pks=None):
         )
 
 
-ContentManager = ContentManager.from_queryset(BulkTouchQuerySet)
+ContentManager = BulkCreateManager.from_queryset(ContentQuerySet)
 
 
 class Content(MasterModel, QueryMixin):
diff --git a/pulpcore/app/viewsets/content.py b/pulpcore/app/viewsets/content.py
--- a/pulpcore/app/viewsets/content.py
+++ b/pulpcore/app/viewsets/content.py
@@ -1,6 +1,8 @@
 from gettext import gettext as _
 
+from django.conf import settings
 from django.db import models
+from django_filters import NumberFilter
 from rest_framework import mixins, permissions, status
 from rest_framework.response import Response
 
@@ -22,6 +24,17 @@
 )
 
 
+class OrphanedFilter(NumberFilter):
+    def filter(self, qs, value):
+        if value is not None:
+            if value < 0:
+                time = settings.ORPHAN_PROTECTION_TIME
+            else:
+                time = value
+            qs = qs.orphaned(int(time))
+        return qs
+
+
 class ArtifactFilter(BaseFilterSet):
     """
     Artifact filter Plugin content filters should:
@@ -33,6 +46,9 @@ class ArtifactFilter(BaseFilterSet):
     """
 
     repository_version = ArtifactRepositoryVersionFilter()
+    orphaned_for = OrphanedFilter(
+        help_text="Minutes Artifacts have been orphaned for. -1 uses ORPHAN_PROTECTION_TIME."
+    )
 
     class Meta:
         model = Artifact
@@ -90,11 +106,17 @@ class ContentFilter(BaseFilterSet):
             Return Content which was added in this repository version.
         repository_version_removed:
             Return Content which was removed from this repository version.
+        orphaned_for:
+            Return Content which has been orphaned for a given number of minutes;
+            -1 uses ORPHAN_PROTECTION_TIME value.
     """
 
     repository_version = ContentRepositoryVersionFilter()
     repository_version_added = ContentAddedRepositoryVersionFilter()
     repository_version_removed = ContentRemovedRepositoryVersionFilter()
+    orphaned_for = OrphanedFilter(
+        help_text="Minutes Content has been orphaned for. -1 uses ORPHAN_PROTECTION_TIME."
+    )
 
 
 class BaseContentViewSet(NamedModelViewSet):


Test patch:
diff --git a/pulpcore/tests/functional/api/using_plugin/test_orphans.py b/pulpcore/tests/functional/api/using_plugin/test_orphans.py
--- a/pulpcore/tests/functional/api/using_plugin/test_orphans.py
+++ b/pulpcore/tests/functional/api/using_plugin/test_orphans.py
@@ -15,6 +15,63 @@ def orphans_api_client(pulpcore_client):
     return OrphansApi(pulpcore_client)
 
 
+def test_content_orphan_filter(
+    file_content_unit_with_name_factory,
+    file_content_api_client,
+    file_repository_factory,
+    file_repository_api_client,
+    monitor_task,
+):
+    content_unit = file_content_unit_with_name_factory("1.iso")
+
+    # test orphan_for with different values
+    content_units = file_content_api_client.list(
+        orphaned_for="0", pulp_href__in=[content_unit.pulp_href]
+    )
+    assert content_units.count == 1
+    content_units = file_content_api_client.list(
+        orphaned_for="100", pulp_href__in=[content_unit.pulp_href]
+    )
+    assert content_units.count == 0
+
+    # add our content unit to a repo
+    repo = file_repository_factory()
+    body = {"add_content_units": [content_unit.pulp_href]}
+    task = file_repository_api_client.modify(repo.pulp_href, body).task
+    monitor_task(task)
+    content_units = file_content_api_client.list(
+        orphaned_for="0", pulp_href__in=[content_unit.pulp_href]
+    )
+    assert content_units.count == 0
+
+
+def test_artifact_orphan_filter(
+    random_artifact,
+    artifacts_api_client,
+    file_content_api_client,
+    monitor_task,
+):
+    # test orphan_for with different values
+    artifacts = artifacts_api_client.list(
+        orphaned_for="0", pulp_href__in=[random_artifact.pulp_href]
+    )
+    assert artifacts.count == 1
+    artifacts = artifacts_api_client.list(
+        orphaned_for="100", pulp_href__in=[random_artifact.pulp_href]
+    )
+    assert artifacts.count == 0
+
+    # create a content unit with the artifact
+    task = file_content_api_client.create(
+        artifact=random_artifact.pulp_href, relative_path="1.iso"
+    ).task
+    monitor_task(task)
+    artifacts = artifacts_api_client.list(
+        orphaned_for="0", pulp_href__in=[random_artifact.pulp_href]
+    )
+    assert artifacts.count == 0
+
+
 def test_orphans_delete(
     random_artifact,
     file_random_content_unit,