Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💄 ✨ Add Container Logs view #127

Merged
merged 15 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion netbox_docker_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>, David Delassus <[email protected]>"
author_email= "[email protected], [email protected]"
Expand Down
14 changes: 14 additions & 0 deletions netbox_docker_plugin/api/renderers.py
Original file line number Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions netbox_docker_plugin/api/views.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"""

Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{% 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-lg btn-outline-primary"
>
Stop Following
</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 isFollowEnabled = () => JSON.parse(logPanel.dataset.follow) === true
const toggleFollow = () => {
logPanel.dataset.follow = !isFollowEnabled()
followButton.classList.toggle("btn-outline-primary")
followButton.classList.toggle("btn-primary")
followButton.textContent = isFollowEnabled() ? "Stop Following" : "Follow"
}

logPanel.addEventListener("htmx:afterSwap", function() {
if (isFollowEnabled()) {
this.scrollTop = this.scrollHeight
}
})

followButton.addEventListener("click", function() {
toggleFollow()
})
})
</script>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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">
Expand Down
37 changes: 37 additions & 0 deletions netbox_docker_plugin/tests/container/test_container_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -75,6 +77,7 @@ def setUpTestData(cls) -> None:
name="container1",
operation="none",
state="created",
ContainerID="1234",
)
Container.objects.create(
host=host1,
Expand Down Expand Up @@ -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")
Loading
Loading