Repository: openfun/marsha

Issue:
Create a management command to clean orphan mediapackage channels
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

Some mediapackage channels remains open while their related medialive channel and input are already deleted. Remaining them open is useless, they will never be used any more.
They remain open when an harvest job fails, we have an [issue](https://github.com/openfun/marsha/issues/1046) to deal with this problem

**Describe the solution you'd like**

A management command can be created for this case. The command will fetch all mediapackage channels and compare them to medialive channels. Then for all orphan mediapackage channels we have to check if an harvest job is ongoing or not. If not or failed then the channel and its endpoint can be deleted



Discussion:

Resolving patch:
diff --git a/src/backend/marsha/core/management/commands/clean_mediapackages.py b/src/backend/marsha/core/management/commands/clean_mediapackages.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/management/commands/clean_mediapackages.py
@@ -0,0 +1,52 @@
+"""Clean orphan mediapackages channel management command."""
+from django.core.management.base import BaseCommand
+
+from marsha.core.utils.medialive_utils import (
+    delete_mediapackage_channel,
+    list_indexed_medialive_channels,
+    list_mediapackage_channel_harvest_jobs,
+    list_mediapackage_channels,
+)
+
+
+class Command(BaseCommand):
+    """Remove orphan mediapackage channels."""
+
+    help = (
+        "Check every mediapackage channels and remove them if no related medialive "
+        "nor harvesting job exists."
+    )
+
+    def handle(self, *args, **options):
+        """Execute management command."""
+        mediapackage_channels = list_mediapackage_channels()
+        indexed_medialive_channels = list_indexed_medialive_channels()
+
+        for mediapackage_channel in mediapackage_channels:
+            mediapackage_channel_id = mediapackage_channel.get("Id")
+            self.stdout.write(
+                f"Processing mediapackage channel {mediapackage_channel_id}"
+            )
+
+            if mediapackage_channel_id in indexed_medialive_channels.keys():
+                continue
+
+            mediapackage_channel_should_be_deleted = True
+            mediapackage_channel_harvest_jobs = list_mediapackage_channel_harvest_jobs(
+                mediapackage_channel_id
+            )
+            for harvest_job in mediapackage_channel_harvest_jobs:
+                mediapackage_channel_should_be_deleted = (
+                    mediapackage_channel_should_be_deleted
+                    & (harvest_job.get("Status") in ("FAILED",))
+                )
+
+            if mediapackage_channel_should_be_deleted:
+                deleted_endpoints = delete_mediapackage_channel(mediapackage_channel_id)
+                for deleted_endpoint in deleted_endpoints:
+                    self.stdout.write(
+                        f"Mediapackage channel endpoint {deleted_endpoint} deleted"
+                    )
+                self.stdout.write(
+                    f"Mediapackage channel {mediapackage_channel_id} deleted"
+                )
diff --git a/src/backend/marsha/core/utils/medialive_utils.py b/src/backend/marsha/core/utils/medialive_utils.py
--- a/src/backend/marsha/core/utils/medialive_utils.py
+++ b/src/backend/marsha/core/utils/medialive_utils.py
@@ -322,3 +322,94 @@ def delete_aws_element_stack(video):
     medialive_client.delete_input(
         InputId=video.live_info.get("medialive").get("input").get("id")
     )
+
+
+def _get_items(  # nosec
+    get_items, items_key, params=None, next_token=None, next_token_key="NextToken"
+):
+    """
+    Recursive generic function call which concatenates results.
+
+    Returns a list of items from the same items_key in response.
+    If response contains a next token, get_items is called again, and items are concatenated.
+
+    Parameters
+    ----------
+    get_items : function
+        function that returns items which may return a next token
+    items_key : string
+        key containing items
+    params : dict
+        params passed to get_items function
+    next_token : string
+        token returned by first get_items call
+    next_token_key : string
+        key from get_items response that may contain a next_token
+
+    Returns
+    -------
+    list
+        list of items returned by recursive get_items calls
+    """
+    if not params:
+        params = {}
+
+    if next_token:
+        params[next_token_key] = next_token
+
+    response = get_items(**params)
+    items = response.get(items_key)
+    next_token = response.get(next_token_key)
+
+    if next_token:
+        items.extend(
+            _get_items(get_items, items_key, params, next_token, next_token_key)
+        )
+    return items
+
+
+def list_mediapackage_channels():
+    """List all mediapackage channels."""
+    return _get_items(mediapackage_client.list_channels, items_key="Channels")
+
+
+def list_mediapackage_channel_harvest_jobs(channel_id):
+    """List all harvest jobs for a mediapackage channel."""
+    return _get_items(
+        mediapackage_client.list_harvest_jobs,
+        items_key="HarvestJobs",
+        params={"IncludeChannelId": channel_id},
+    )
+
+
+def list_mediapackage_channel_origin_endpoints(channel_id):
+    """List all origin endpoints for a mediapackage channel."""
+    return _get_items(
+        mediapackage_client.list_origin_endpoints,
+        items_key="OriginEndpoints",
+        params={"ChannelId": channel_id},
+    )
+
+
+def list_medialive_channels():
+    """List all medialive channels."""
+    return _get_items(medialive_client.list_channels, items_key="Channels")
+
+
+def list_indexed_medialive_channels():
+    """List and index all medialive channels by their name."""
+    return {
+        medialive_channel.get("Name"): medialive_channel
+        for medialive_channel in list_medialive_channels()
+    }
+
+
+def delete_mediapackage_channel(channel_id):
+    """Delete a mediapackage channel and related enpoints."""
+    origin_endpoints = list_mediapackage_channel_origin_endpoints(channel_id=channel_id)
+    deleted_endpoints = []
+    for origin_endpoint in origin_endpoints:
+        mediapackage_client.delete_origin_endpoint(Id=origin_endpoint.get("Id"))
+        deleted_endpoints.append(origin_endpoint.get("Id"))
+    mediapackage_client.delete_channel(Id=channel_id)
+    return deleted_endpoints


Test patch:
diff --git a/src/backend/marsha/core/tests/test_command_clean_mediapackages.py b/src/backend/marsha/core/tests/test_command_clean_mediapackages.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/tests/test_command_clean_mediapackages.py
@@ -0,0 +1,169 @@
+"""Tests for clean_mediapackages command."""
+from io import StringIO
+from unittest import mock
+
+from django.core.management import call_command
+from django.test import TestCase
+
+from ..management.commands import clean_mediapackages
+
+
+class CleanMediapackagesTest(TestCase):
+    """Test clean_mediapackages command."""
+
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_no_mediapackage(
+        self, mock_mediapackage_channels, mock_medialive_indexed_channels
+    ):
+        """Command should do nothing when there is no mediapackage to process."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = []
+        mock_medialive_indexed_channels.return_value = {}
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertEqual("", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_related_medialive(
+        self, mock_mediapackage_channels, mock_medialive_indexed_channels
+    ):
+        """Command should do nothing when there is related medialives."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}, {"Id": "MP2"}]
+        mock_medialive_indexed_channels.return_value = {
+            "MP1": {"Id": "ML1", "Name": "MP1"},
+            "MP2": {"Id": "ML2", "Name": "MP2"},
+        }
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertIn("Processing mediapackage channel MP2", out.getvalue())
+        self.assertNotIn("Mediapackage channel MP1 deleted", out.getvalue())
+        self.assertNotIn("Mediapackage channel MP2 deleted", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channel_harvest_jobs")
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_harvest_job_pending(
+        self,
+        mock_mediapackage_channels,
+        mock_medialive_indexed_channels,
+        mock_harvest_jobs,
+    ):
+        """Command should do nothing when there is a pending harvest job."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}]
+        mock_medialive_indexed_channels.return_value = {}
+        mock_harvest_jobs.return_value = [{"Status": "PENDING"}]
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertNotIn("Mediapackage channel MP1 deleted", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "delete_mediapackage_channel")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channel_harvest_jobs")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    def test_clean_mediapackages_harvest_job_failed(
+        self,
+        mock_medialive_indexed_channels,
+        mock_mediapackage_channels,
+        mock_harvest_jobs,
+        mock_delete_mediapackage,
+    ):
+        """Command should delete channel when only a failed harvest job exists."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}]
+        mock_medialive_indexed_channels.return_value = {}
+        mock_harvest_jobs.return_value = [{"Status": "FAILED"}]
+        mock_delete_mediapackage.return_value = ["EP1", "EP2"]
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertIn("Mediapackage channel endpoint EP1 deleted", out.getvalue())
+        self.assertIn("Mediapackage channel endpoint EP2 deleted", out.getvalue())
+        self.assertIn("Mediapackage channel MP1 deleted", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channel_harvest_jobs")
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_harvest_jobs_failed_and_pending(
+        self,
+        mock_mediapackage_channels,
+        mock_medialive_indexed_channels,
+        mock_harvest_jobs,
+    ):
+        """Command should do nothing when failed and pending harvest job exists."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}]
+        mock_medialive_indexed_channels.return_value = {}
+        mock_harvest_jobs.return_value = [
+            {"Status": "FAILED"},
+            {"Status": "PENDING"},
+        ]
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertNotIn("Mediapackage channel MP1 deleted", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channel_harvest_jobs")
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_harvest_jobs_pending_and_failed(
+        self,
+        mock_mediapackage_channels,
+        mock_medialive_indexed_channels,
+        mock_harvest_jobs,
+    ):
+        """Command should do nothing when pending and failed harvest job exists."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}]
+        mock_medialive_indexed_channels.return_value = {}
+        mock_harvest_jobs.return_value = [
+            {"Status": "PENDING"},
+            {"Status": "FAILED"},
+        ]
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertNotIn("Mediapackage channel MP1 deleted", out.getvalue())
+        out.close()
+
+    @mock.patch.object(clean_mediapackages, "delete_mediapackage_channel")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channel_harvest_jobs")
+    @mock.patch.object(clean_mediapackages, "list_indexed_medialive_channels")
+    @mock.patch.object(clean_mediapackages, "list_mediapackage_channels")
+    def test_clean_mediapackages_no_harvest_job(
+        self,
+        mock_mediapackage_channels,
+        mock_medialive_indexed_channels,
+        mock_harvest_jobs,
+        mock_delete_mediapackage,
+    ):
+        """Command should delete channel when no harvest job exists."""
+        out = StringIO()
+        mock_mediapackage_channels.return_value = [{"Id": "MP1"}]
+        mock_medialive_indexed_channels.return_value = {}
+        mock_harvest_jobs.return_value = []
+        mock_delete_mediapackage.return_value = ["EP1", "EP2"]
+
+        call_command("clean_mediapackages", stdout=out)
+
+        self.assertIn("Processing mediapackage channel MP1", out.getvalue())
+        self.assertIn("Mediapackage channel endpoint EP1 deleted", out.getvalue())
+        self.assertIn("Mediapackage channel endpoint EP2 deleted", out.getvalue())
+        self.assertIn("Mediapackage channel MP1 deleted", out.getvalue())
+        out.close()
diff --git a/src/backend/marsha/core/tests/test_utils_medialive_utils.py b/src/backend/marsha/core/tests/test_utils_medialive_utils.py
--- a/src/backend/marsha/core/tests/test_utils_medialive_utils.py
+++ b/src/backend/marsha/core/tests/test_utils_medialive_utils.py
@@ -789,3 +789,136 @@ def test_delete_aws_elemental_stack(self):
             )
 
             medialive_utils.delete_aws_element_stack(video)
+
+    def test_list_mediapackage_channels(self):
+        """Should recursively get all mediapackage channels."""
+        with Stubber(
+            medialive_utils.mediapackage_client
+        ) as mediapackage_client_stubber:
+            mediapackage_client_stubber.add_response(
+                "list_channels",
+                service_response={"Channels": [{"Id": "1"}], "NextToken": "next_token"},
+                expected_params={},
+            )
+            mediapackage_client_stubber.add_response(
+                "list_channels",
+                service_response={"Channels": [{"Id": "2"}]},
+                expected_params={"NextToken": "next_token"},
+            )
+            channels = medialive_utils.list_mediapackage_channels()
+            mediapackage_client_stubber.assert_no_pending_responses()
+        self.assertEqual(channels, [{"Id": "1"}, {"Id": "2"}])
+
+    def test_list_mediapackage_channel_harvest_jobs(self):
+        """Should recursively get all harvest job related to a mediapackage channel."""
+        with Stubber(
+            medialive_utils.mediapackage_client
+        ) as mediapackage_client_stubber:
+            mediapackage_client_stubber.add_response(
+                "list_harvest_jobs",
+                service_response={
+                    "HarvestJobs": [{"Id": "1"}],
+                    "NextToken": "next_token",
+                },
+                expected_params={"IncludeChannelId": "1"},
+            )
+            mediapackage_client_stubber.add_response(
+                "list_harvest_jobs",
+                service_response={"HarvestJobs": [{"Id": "2"}]},
+                expected_params={"IncludeChannelId": "1", "NextToken": "next_token"},
+            )
+            harvest_jobs = medialive_utils.list_mediapackage_channel_harvest_jobs("1")
+            mediapackage_client_stubber.assert_no_pending_responses()
+        self.assertEqual(harvest_jobs, [{"Id": "1"}, {"Id": "2"}])
+
+    def test_list_mediapackage_channel_origin_endpoints(self):
+        """Should recursively get all origin endpoints related to a mediapackage channel."""
+        with Stubber(
+            medialive_utils.mediapackage_client
+        ) as mediapackage_client_stubber:
+            mediapackage_client_stubber.add_response(
+                "list_origin_endpoints",
+                service_response={
+                    "OriginEndpoints": [{"Id": "1"}],
+                    "NextToken": "next_token",
+                },
+                expected_params={"ChannelId": "1"},
+            )
+            mediapackage_client_stubber.add_response(
+                "list_origin_endpoints",
+                service_response={"OriginEndpoints": [{"Id": "2"}]},
+                expected_params={"ChannelId": "1", "NextToken": "next_token"},
+            )
+            origin_endpoints = (
+                medialive_utils.list_mediapackage_channel_origin_endpoints("1")
+            )
+            mediapackage_client_stubber.assert_no_pending_responses()
+        self.assertEqual(origin_endpoints, [{"Id": "1"}, {"Id": "2"}])
+
+    def test_list_medialive_channels(self):
+        """Should recursively get all medialive channels."""
+        with Stubber(medialive_utils.medialive_client) as medialive_client_stubber:
+            medialive_client_stubber.add_response(
+                "list_channels",
+                service_response={"Channels": [{"Id": "1"}], "NextToken": "next_token"},
+                expected_params={},
+            )
+            medialive_client_stubber.add_response(
+                "list_channels",
+                service_response={"Channels": [{"Id": "2"}]},
+                expected_params={"NextToken": "next_token"},
+            )
+            channels = medialive_utils.list_medialive_channels()
+            medialive_client_stubber.assert_no_pending_responses()
+        self.assertEqual(channels, [{"Id": "1"}, {"Id": "2"}])
+
+    def test_list_indexed_medialive_channels(self):
+        """Should recursively get all medialive channels and index them by their name."""
+        with Stubber(medialive_utils.medialive_client) as medialive_client_stubber:
+            medialive_client_stubber.add_response(
+                "list_channels",
+                service_response={
+                    "Channels": [{"Id": "1", "Name": "A"}],
+                    "NextToken": "next_token",
+                },
+                expected_params={},
+            )
+            medialive_client_stubber.add_response(
+                "list_channels",
+                service_response={"Channels": [{"Id": "2", "Name": "B"}]},
+                expected_params={"NextToken": "next_token"},
+            )
+            channels = medialive_utils.list_indexed_medialive_channels()
+            medialive_client_stubber.assert_no_pending_responses()
+        self.assertEqual(
+            channels, {"A": {"Id": "1", "Name": "A"}, "B": {"Id": "2", "Name": "B"}}
+        )
+
+    def test_delete_mediapackage_channel(self):
+        """Should delete a mediapackage channel and related enpoints."""
+        with Stubber(
+            medialive_utils.mediapackage_client
+        ) as mediapackage_client_stubber:
+            mediapackage_client_stubber.add_response(
+                "list_origin_endpoints",
+                service_response={"OriginEndpoints": [{"Id": "1"}, {"Id": "2"}]},
+                expected_params={"ChannelId": "1"},
+            )
+            mediapackage_client_stubber.add_response(
+                "delete_origin_endpoint",
+                service_response={},
+                expected_params={"Id": "1"},
+            )
+            mediapackage_client_stubber.add_response(
+                "delete_origin_endpoint",
+                service_response={},
+                expected_params={"Id": "2"},
+            )
+            mediapackage_client_stubber.add_response(
+                "delete_channel",
+                service_response={},
+                expected_params={"Id": "1"},
+            )
+            deleted_endpoints = medialive_utils.delete_mediapackage_channel("1")
+            mediapackage_client_stubber.assert_no_pending_responses()
+        self.assertEqual(deleted_endpoints, ["1", "2"])