Repository: SaaShup/netbox-docker-plugin (language: python)

Issue:
:sparkles: Access logs of a container via Netbox
| Netbox Version | Plugin Version | Agent Version | Docker API Version |
| --- | --- | --- | --- |
| 3.6.9 | 1.3.0 | 0.12.1 | N/A |

## Actual Behavior

To access the logs of a container, we need to either:

 - connect to the host to run `docker logs $container_name`
 - redirect the docker host's logs to a third-party service like OpenObserve, or Splunk

## Desired Behavior

If possible, it would be nice to have a button `Logs` in the detailed view of a container in Netbox that would stream the logs of the container.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml
index 2463571..6b375e2 100644
--- a/.github/workflows/main_ci.yml
+++ b/.github/workflows/main_ci.yml
@@ -49,6 +49,7 @@ jobs:
         working-directory: netbox-docker-plugin
         run: |
           pip install .
+          pip install requests_mock
       - name: Install netbox Dependencies
         working-directory: netbox
         run: |
diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml
index 88dc483..1f68a5e 100644
--- a/.github/workflows/pr_ci.yml
+++ b/.github/workflows/pr_ci.yml
@@ -52,6 +52,7 @@ jobs:
         working-directory: netbox-docker-plugin
         run: |
           pip install .
+          pip install requests_mock
       - name: Install netbox Dependencies
         working-directory: netbox
         run: |
diff --git a/README.md b/README.md
index 1bc3293..c0227cf 100644
--- a/README.md
+++ b/README.md
@@ -153,10 +153,12 @@ Visit http://localhost:8000/
 
 ### Run tests
 
-After installing you development environment, you can run the tests plugin (you don't need to start the Netbox instance):
+After installing your development environment, you can run the tests plugin
+(you don't need to start the Netbox instance):
 
 ```bash
 cd $PROJECT/netbox
+python3 -m pip install requests_mock
 python3 netbox/manage.py test netbox_docker_plugin.tests --keepdb -v 2
 ```
 
@@ -167,7 +169,7 @@ cd $PROJECT/netbox
 python3 -m pip install coverage
 ```
 
-The run the test with coverage.py and print the report:
+Then run the test with coverage.py and print the report:
 
 ```bash
 cd $PROJECT/netbox
diff --git a/netbox_docker_plugin/__init__.py b/netbox_docker_plugin/__init__.py
index 60ea05c..c235762 100644
--- a/netbox_docker_plugin/__init__.py
+++ b/netbox_docker_plugin/__init__.py
@@ -10,7 +10,7 @@ class NetBoxDockerConfig(PluginConfig):
     name = "netbox_docker_plugin"
     verbose_name = " NetBox Docker Plugin"
     description = "Manage Docker"
-    version = "1.8.1"
+    version = "1.9.0"
     base_url = "docker"
     author= "Vincent Simonin <vincent@saashup.com>, David Delassus <david.jose.delassus@gmail.com>"
     author_email= "vincent@saashup.com, david.jose.delassus@gmail.com"
diff --git a/netbox_docker_plugin/api/renderers.py b/netbox_docker_plugin/api/renderers.py
new file mode 100644
index 0000000..7e078a6
--- /dev/null
+++ b/netbox_docker_plugin/api/renderers.py
@@ -0,0 +1,14 @@
+""" Response renderers for Django Rest Framework """
+
+from django.utils.encoding import smart_str
+from rest_framework import renderers
+
+
+class PlainTextRenderer(renderers.BaseRenderer):
+    """ text/plain renderer """
+
+    media_type = "text/plain"
+    format = "txt"
+
+    def render(self, data, accepted_media_type=None, renderer_context=None):
+        return smart_str(data, encoding=self.charset)
diff --git a/netbox_docker_plugin/api/views.py b/netbox_docker_plugin/api/views.py
index 682c434..d548c22 100644
--- a/netbox_docker_plugin/api/views.py
+++ b/netbox_docker_plugin/api/views.py
@@ -1,9 +1,17 @@
 """API views definitions"""
 
 from collections.abc import Sequence
+from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
+from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.response import Response
+import requests
+
 from users.models import Token
 from netbox.api.viewsets import NetBoxModelViewSet
+
 from .. import filtersets
+from .renderers import PlainTextRenderer
 from .serializers import (
     HostSerializer,
     ImageSerializer,
@@ -96,6 +104,63 @@ class ContainerViewSet(NetBoxModelViewSet):
     http_method_names = ["get", "post", "patch", "delete", "options"]
 
 
+    @extend_schema(
+        operation_id="plugins_docker_container_logs",
+        responses={
+            (200, "text/plain"): OpenApiResponse(
+                response=str,
+                examples=[
+                    OpenApiExample(
+                        "Container's logs",
+                        value="Hello World",
+                        media_type="text/plain",
+                    ),
+                ],
+            ),
+            (502, "text/plain"): OpenApiResponse(
+                response=str,
+                examples=[
+                    OpenApiExample(
+                        "Engine error",
+                        value="Error as returned by Agent",
+                        media_type="text/plain",
+                    ),
+                ],
+            ),
+        },
+    )
+    @action(
+        detail=True,
+        methods=["get"],
+        renderer_classes=[PlainTextRenderer],
+    )
+    def logs(self, _request, **_kwargs):
+        """ Fetch container's logs """
+
+        container: Container = self.get_object()
+        agent_url = container.host.endpoint
+        container_id = container.ContainerID
+
+        url = f"{agent_url}/api/engine/containers/{container_id}/logs"
+
+        try:
+            resp = requests.get(url, timeout=10)
+            resp.raise_for_status()
+
+        except requests.HTTPError:
+            return Response(
+                resp.text,
+                status=status.HTTP_502_BAD_GATEWAY,
+                content_type="text/plain",
+            )
+
+        return Response(
+            resp.text,
+            status=status.HTTP_200_OK,
+            content_type="text/plain",
+        )
+
+
 class RegistryViewSet(NetBoxModelViewSet):
     """Registry view set class"""
 
diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/container-layout.html b/netbox_docker_plugin/templates/netbox_docker_plugin/container-layout.html
new file mode 100644
index 0000000..9270238
--- /dev/null
+++ b/netbox_docker_plugin/templates/netbox_docker_plugin/container-layout.html
@@ -0,0 +1,61 @@
+{% extends 'generic/object.html' %}
+
+{% load plugins %}
+
+{% block extra_controls %}
+<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='start' %}" method="post">
+  {% csrf_token %}
+  <input type="hidden" name="operation" value="start">
+  <button
+    type="submit"
+    class="btn btn-sm btn-success"
+    {% if not object.can_start %}
+    disabled
+    {% endif %}
+  >
+    <i class="mdi mdi-play"></i> Start
+  </button>
+</form>
+
+<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='stop' %}" method="post">
+  {% csrf_token %}
+  <input type="hidden" name="operation" value="stop">
+  <button
+    type="submit"
+    class="btn btn-sm btn-danger"
+    {% if not object.can_stop %}
+    disabled
+    {% endif %}
+  >
+    <i class="mdi mdi-stop"></i> Stop
+  </button>
+</form>
+
+<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='restart' %}" method="post">
+  {% csrf_token %}
+  <input type="hidden" name="operation" value="restart">
+  <button
+    type="submit"
+    class="btn btn-sm btn-light"
+    {% if not object.can_restart %}
+    disabled
+    {% endif %}
+  >
+    <i class="mdi mdi-restart"></i> Restart
+  </button>
+</form>
+
+<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='recreate' %}" method="post">
+  {% csrf_token %}
+  <input type="hidden" name="operation" value="recreate">
+  <button
+    type="submit"
+    class="btn btn-sm btn-dark"
+    {% if not object.can_recreate %}
+    disabled
+    {% endif %}
+  >
+    <i class="mdi mdi-autorenew"></i> Recreate
+  </button>
+</form>
+{% endblock %}
diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/container-logs.html b/netbox_docker_plugin/templates/netbox_docker_plugin/container-logs.html
new file mode 100644
index 0000000..4c938df
--- /dev/null
+++ b/netbox_docker_plugin/templates/netbox_docker_plugin/container-logs.html
@@ -0,0 +1,63 @@
+{% extends "netbox_docker_plugin/container-layout.html" %}
+
+{% load plugins %}
+
+{% block content %}
+<div class="row mb-3">
+  <div class="col col-md-12">
+    <div class="card">
+      <div class="card-header">
+        <div class="d-sm-flex p-2 flex-row align-items-center">
+          <h5 class="flex-grow-1">LOGS</h5>
+          <button
+            id="container-logs-button"
+            type="button"
+            class="btn btn-primary"
+          >
+            <i id="container-logs-cb" class="mdi mdi-checkbox-marked"></i> Follow logs
+          </button>
+        </div>
+      <div class="card-body">
+        <pre
+          id="container-logs-panel"
+          data-follow="true"
+          class="p-2 text-white border-dark"
+          style="height: 300px; overflow-y: auto; background-color: black;"
+          hx-get="/api/plugins/docker/containers/{{ object.pk }}/logs/"
+          hx-trigger="load, every 30s"
+        >
+        </pre>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}
+
+{% block head %}
+<script type="application/javascript">
+htmx.onLoad(function(elt) {
+  const logPanel = document.getElementById("container-logs-panel")
+  const followButton = document.getElementById("container-logs-button")
+  const checkbox = document.getElementById("container-logs-cb")
+
+  const isFollowEnabled = () => JSON.parse(logPanel.dataset.follow) === true
+  const toggleFollow = () => {
+    logPanel.dataset.follow = !isFollowEnabled()
+    followButton.classList.toggle("btn-primary")
+    followButton.classList.toggle("btn-outline-primary")
+    checkbox.classList.toggle("mdi-checkbox-marked")
+    checkbox.classList.toggle("mdi-checkbox-blank-outline")
+  }
+
+  logPanel.addEventListener("htmx:afterSwap", function() {
+    if (isFollowEnabled()) {
+      this.scrollTop = this.scrollHeight
+    }
+  })
+
+  followButton.addEventListener("click", function() {
+    toggleFollow()
+  })
+})
+</script>
+{% endblock %}
diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/container.html b/netbox_docker_plugin/templates/netbox_docker_plugin/container.html
index 424425f..b51edf9 100644
--- a/netbox_docker_plugin/templates/netbox_docker_plugin/container.html
+++ b/netbox_docker_plugin/templates/netbox_docker_plugin/container.html
@@ -1,65 +1,7 @@
-{% extends 'generic/object.html' %}
+{% extends "netbox_docker_plugin/container-layout.html" %}
 
 {% load plugins %}
 
-{% block extra_controls %}
-<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='start' %}" method="post">
-  {% csrf_token %}
-  <input type="hidden" name="operation" value="start">
-  <button
-    type="submit"
-    class="btn btn-sm btn-success"
-    {% if not object.can_start %}
-    disabled
-    {% endif %}
-  >
-    <i class="mdi mdi-play"></i> Start
-  </button>
-</form>
-
-<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='stop' %}" method="post">
-  {% csrf_token %}
-  <input type="hidden" name="operation" value="stop">
-  <button
-    type="submit"
-    class="btn btn-sm btn-danger"
-    {% if not object.can_stop %}
-    disabled
-    {% endif %}
-  >
-    <i class="mdi mdi-stop"></i> Stop
-  </button>
-</form>
-
-<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='restart' %}" method="post">
-  {% csrf_token %}
-  <input type="hidden" name="operation" value="restart">
-  <button
-    type="submit"
-    class="btn btn-sm btn-light"
-    {% if not object.can_restart %}
-    disabled
-    {% endif %}
-  >
-    <i class="mdi mdi-restart"></i> Restart
-  </button>
-</form>
-
-<form action="{% url 'plugins:netbox_docker_plugin:container_operation' pk=object.id operation='recreate' %}" method="post">
-  {% csrf_token %}
-  <input type="hidden" name="operation" value="recreate">
-  <button
-    type="submit"
-    class="btn btn-sm btn-dark"
-    {% if not object.can_recreate %}
-    disabled
-    {% endif %}
-  >
-    <i class="mdi mdi-autorenew"></i> Recreate
-  </button>
-</form>
-{% endblock %}
-
 {% block content %}
 <div class="row mb-3">
   <div class="col col-md-6">
diff --git a/netbox_docker_plugin/urls.py b/netbox_docker_plugin/urls.py
index 84b7a89..5c1e5cf 100644
--- a/netbox_docker_plugin/urls.py
+++ b/netbox_docker_plugin/urls.py
@@ -196,6 +196,11 @@
         container_views.ContainerView.as_view(),
         name="container",
     ),
+    path(
+        "containers/<int:pk>/logs",
+        container_views.ContainerLogsView.as_view(),
+        name="container_logs",
+    ),
     path(
         "containers/<int:pk>/edit/",
         container_views.ContainerEditView.as_view(),
diff --git a/netbox_docker_plugin/views/container.py b/netbox_docker_plugin/views/container.py
index 044087d..113f72e 100644
--- a/netbox_docker_plugin/views/container.py
+++ b/netbox_docker_plugin/views/container.py
@@ -1,6 +1,7 @@
 """Container views definitions"""
 
 from utilities.utils import count_related
+from utilities.views import ViewTab, register_model_view
 from netbox.views import generic
 from .. import tables, filtersets
 from ..forms import container
@@ -15,6 +16,7 @@
 )
 
 
+@register_model_view(Container)
 class ContainerView(generic.ObjectView):
     """Container view definition"""
 
@@ -30,6 +32,15 @@ class ContainerView(generic.ObjectView):
     )
 
 
+@register_model_view(Container, name="logs", path="logs")
+class ContainerLogsView(generic.ObjectView):
+    """ Logs tab in Container view """
+
+    queryset = Container.objects.all()
+    tab = ViewTab(label="Logs")
+    template_name = "netbox_docker_plugin/container-logs.html"
+
+
 class ContainerEditView(generic.ObjectEditView):
     """Container edition view definition"""
 
diff --git a/pyproject.toml b/pyproject.toml
index 43c9caa..2a4e037 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "netbox-docker-plugin"
-version = "1.8.1"
+version = "1.9.0"
 authors = [
   { name="Vincent Simonin", email="vincent@saashup.com" },
   { name="David Delassus", email="david.jose.delassus@gmail.com" }


Test patch:
diff --git a/netbox_docker_plugin/tests/container/test_container_api.py b/netbox_docker_plugin/tests/container/test_container_api.py
index 131f008..6180544 100644
--- a/netbox_docker_plugin/tests/container/test_container_api.py
+++ b/netbox_docker_plugin/tests/container/test_container_api.py
@@ -14,6 +14,8 @@
 from netbox_docker_plugin.models.registry import Registry
 from netbox_docker_plugin.tests.base import BaseAPITestCase
 
+import requests_mock
+
 
 class ContainerApiTestCase(
     BaseAPITestCase,
@@ -75,6 +77,7 @@ def setUpTestData(cls) -> None:
             name="container1",
             operation="none",
             state="created",
+            ContainerID="1234",
         )
         Container.objects.create(
             host=host1,
@@ -237,3 +240,37 @@ def test_that_patch_overwrites_data_only_when_explicitly_set(self):
 
         self.assertEqual(Bind.objects.filter(container=container11).count(), 0)
         self.assertEqual(Env.objects.filter(container=container11).count(), 1)
+
+
+    def test_logs_endpoint(self):
+        """ Test logs endpoint """
+
+        container = Container.objects.get(name="container1")
+        container_id = container.ContainerID
+
+        endpoint = reverse(
+            viewname=f"plugins-api:{self._get_view_namespace()}:container-logs",
+            kwargs={"pk": container.pk},
+        )
+
+        # Add object-level permission
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            constraints={"pk": container.pk},
+            actions=["view"],
+        )
+        obj_perm.save()
+        # pylint: disable=E1101
+        obj_perm.users.add(self.user)
+        # pylint: disable=E1101
+        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
+        with requests_mock.Mocker() as m:
+            m.get(
+                f"http://localhost:8080/api/engine/containers/{container_id}/logs",
+                text="Hello World",
+            )
+
+            response = self.client.get(endpoint, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            self.assertEqual(response.data, "Hello World")