Skip to content

Commit ea13d79

Browse files
authored
Merge pull request #130 from SaaShup/container_exec
✨ Container exec
2 parents ff4dc3b + 608c639 commit ea13d79

File tree

9 files changed

+296
-45
lines changed

9 files changed

+296
-45
lines changed

netbox_docker_plugin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class NetBoxDockerConfig(PluginConfig):
1010
name = "netbox_docker_plugin"
1111
verbose_name = " NetBox Docker Plugin"
1212
description = "Manage Docker"
13-
version = "1.10.0"
13+
version = "1.11.0"
1414
base_url = "docker"
1515
author= "Vincent Simonin <[email protected]>, David Delassus <[email protected]>"
1616

netbox_docker_plugin/api/serializers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,3 +584,14 @@ class Meta:
584584
"containers",
585585
"registries",
586586
)
587+
588+
class ContainerCommandSerializer(serializers.Serializer):
589+
"""Container command Serializer class"""
590+
591+
cmd=serializers.ListField(child=serializers.CharField())
592+
593+
def create(self, validated_data):
594+
pass
595+
596+
def update(self, instance, validated_data):
597+
pass

netbox_docker_plugin/api/views.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
"""API views definitions"""
22

33
from collections.abc import Sequence
4-
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
4+
import json
5+
import requests
56
from rest_framework import status
7+
from rest_framework.renderers import JSONRenderer
68
from rest_framework.decorators import action
79
from rest_framework.response import Response
8-
import requests
9-
10+
from drf_spectacular.utils import (
11+
extend_schema,
12+
OpenApiResponse,
13+
OpenApiExample,
14+
)
1015
from users.models import Token
1116
from netbox.api.viewsets import NetBoxModelViewSet
12-
1317
from .. import filtersets
1418
from .renderers import PlainTextRenderer
1519
from .serializers import (
@@ -19,6 +23,7 @@
1923
NetworkSerializer,
2024
ContainerSerializer,
2125
RegistrySerializer,
26+
ContainerCommandSerializer,
2227
)
2328
from ..models.host import Host
2429
from ..models.image import Image
@@ -103,7 +108,6 @@ class ContainerViewSet(NetBoxModelViewSet):
103108
serializer_class = ContainerSerializer
104109
http_method_names = ["get", "post", "patch", "delete", "options"]
105110

106-
107111
@extend_schema(
108112
operation_id="plugins_docker_container_logs",
109113
responses={
@@ -135,7 +139,7 @@ class ContainerViewSet(NetBoxModelViewSet):
135139
renderer_classes=[PlainTextRenderer],
136140
)
137141
def logs(self, _request, **_kwargs):
138-
""" Fetch container's logs """
142+
"""Fetch container's logs"""
139143

140144
container: Container = self.get_object()
141145
agent_url = container.host.endpoint
@@ -160,6 +164,64 @@ def logs(self, _request, **_kwargs):
160164
content_type="text/plain",
161165
)
162166

167+
@extend_schema(
168+
operation_id="plugins_docker_container_exec",
169+
request=ContainerCommandSerializer,
170+
responses={
171+
(200, "text/plain"): OpenApiResponse(
172+
response=str,
173+
examples=[
174+
OpenApiExample(
175+
"Command output",
176+
value={"stdout": "..."},
177+
media_type="application/json",
178+
),
179+
],
180+
),
181+
(502, "text/plain"): OpenApiResponse(
182+
response=str,
183+
examples=[
184+
OpenApiExample(
185+
"Engine error",
186+
value="Error as returned by Agent",
187+
media_type="text/plain",
188+
),
189+
],
190+
),
191+
},
192+
)
193+
@action(detail=True, methods=["post"], renderer_classes=[JSONRenderer])
194+
def exec(self, _request, **_kwargs):
195+
"""Exec a command on a Container"""
196+
197+
serializer = ContainerCommandSerializer(data=_request.data)
198+
if not serializer.is_valid():
199+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
200+
201+
container: Container = self.get_object()
202+
agent_url = container.host.endpoint
203+
container_id = container.ContainerID
204+
205+
url = f"{agent_url}/api/engine/containers/{container_id}/exec"
206+
207+
try:
208+
resp = requests.put(
209+
url,
210+
data=json.dumps(serializer.validated_data),
211+
timeout=30,
212+
headers={"Content-Type": "application/json"},
213+
)
214+
resp.raise_for_status()
215+
216+
except requests.HTTPError:
217+
return Response(
218+
resp.text,
219+
status=status.HTTP_502_BAD_GATEWAY,
220+
content_type="text/plain",
221+
)
222+
223+
return Response(data=json.loads(resp.text))
224+
163225

164226
class RegistryViewSet(NetBoxModelViewSet):
165227
"""Registry view set class"""
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{% extends "netbox_docker_plugin/container-layout.html" %}
2+
3+
{% load plugins %}
4+
5+
{% block content %}
6+
<div class="row mb-3">
7+
<div class="col col-md-12">
8+
<div class="card">
9+
<h5 class="card-header">EXEC</h5>
10+
<div class="card-body">
11+
<p class="input-group">
12+
<label class="input-group-text" for="container-exec-command">Command</label>
13+
<input type="text" class="form-control" id="container-exec-command" data-id="{{ object.pk }}">
14+
<button class="btn btn-primary" id="container-exec-button"><i class="mdi mdi-play"></i> Exec</button>
15+
</p>
16+
<pre id="container-exec-output" data-follow="true" class="p-2 text-white border-dark"
17+
style="height: 300px; overflow-y: auto; background-color: black; resize: vertical;">
18+
</pre>
19+
</div>
20+
</div>
21+
</div>
22+
</div>
23+
{% endblock %}
24+
25+
{% block head %}
26+
<script type="application/javascript">
27+
document.addEventListener("DOMContentLoaded", () => {
28+
const input = document.getElementById("container-exec-command");
29+
const button = document.getElementById("container-exec-button");
30+
const pre = document.getElementById("container-exec-output");
31+
32+
input.addEventListener("keypress", (event) => {
33+
if (event.key == "Enter") {
34+
exec(input.dataset.id, input.value.split(" "), pre);
35+
}
36+
});
37+
38+
button.addEventListener("click", () => {
39+
exec(input.dataset.id, input.value.split(" "), pre);
40+
});
41+
42+
const exec = async (id, cmd, elemOutput) => {
43+
document.querySelectorAll(".alert").forEach(e => e.remove());
44+
45+
while (elemOutput.firstChild) {
46+
elemOutput.removeChild(elemOutput.lastChild);
47+
}
48+
49+
if (cmd.length > 0 && cmd[0].length > 0) {
50+
const response = await fetch(`/api/plugins/docker/containers/${id}/exec/`, {
51+
method: "POST",
52+
headers: {
53+
"Content-Type": "application/json",
54+
"X-CSRFToken": "{{ csrf_token }}"
55+
},
56+
body: JSON.stringify({
57+
cmd: cmd
58+
})
59+
});
60+
61+
if (response.ok) {
62+
elemOutput.appendChild(document.createTextNode((await response.json()).stdout))
63+
64+
return;
65+
}
66+
67+
const alert = document.createElement("div");
68+
alert.classList.add("alert", "alert-danger");
69+
alert.appendChild(document.createTextNode("Command exec failed!"));
70+
71+
elemOutput.before(alert);
72+
}
73+
}
74+
});
75+
</script>
76+
{% endblock %}

netbox_docker_plugin/templates/netbox_docker_plugin/container-logs.html

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,18 @@
66
<div class="row mb-3">
77
<div class="col col-md-12">
88
<div class="card">
9-
<div class="card-header">
10-
<div class="d-sm-flex p-2 flex-row align-items-center">
11-
<h5 class="flex-grow-1">LOGS</h5>
12-
<button
13-
id="container-logs-button"
14-
type="button"
15-
class="btn btn-primary"
16-
>
9+
<div class="card-header d-flex">
10+
<h5 class="flex-grow-1">LOGS</h5>
11+
<div>
12+
<button id="container-logs-button" type="button" class="btn btn-primary btn-sm">
1713
<i id="container-logs-cb" class="mdi mdi-checkbox-marked"></i> Follow logs
1814
</button>
1915
</div>
16+
</div>
2017
<div class="card-body">
21-
<pre
22-
id="container-logs-panel"
23-
data-follow="true"
24-
class="p-2 text-white border-dark"
25-
style="height: 300px; overflow-y: auto; background-color: black;"
26-
hx-get="/api/plugins/docker/containers/{{ object.pk }}/logs/"
27-
hx-trigger="load, every 30s"
28-
>
18+
<pre id="container-logs-panel" data-follow="true" class="p-2 text-white border-dark"
19+
style="height: 300px; overflow-y: auto; background-color: black; resize: vertical;"
20+
hx-get="/api/plugins/docker/containers/{{ object.pk }}/logs/" hx-trigger="load, every 30s">
2921
</pre>
3022
</div>
3123
</div>
@@ -35,29 +27,29 @@ <h5 class="flex-grow-1">LOGS</h5>
3527

3628
{% block head %}
3729
<script type="application/javascript">
38-
htmx.onLoad(function(elt) {
39-
const logPanel = document.getElementById("container-logs-panel")
40-
const followButton = document.getElementById("container-logs-button")
41-
const checkbox = document.getElementById("container-logs-cb")
42-
43-
const isFollowEnabled = () => JSON.parse(logPanel.dataset.follow) === true
44-
const toggleFollow = () => {
45-
logPanel.dataset.follow = !isFollowEnabled()
46-
followButton.classList.toggle("btn-primary")
47-
followButton.classList.toggle("btn-outline-primary")
48-
checkbox.classList.toggle("mdi-checkbox-marked")
49-
checkbox.classList.toggle("mdi-checkbox-blank-outline")
50-
}
30+
htmx.onLoad(function (elt) {
31+
const logPanel = document.getElementById("container-logs-panel")
32+
const followButton = document.getElementById("container-logs-button")
33+
const checkbox = document.getElementById("container-logs-cb")
5134

52-
logPanel.addEventListener("htmx:afterSwap", function() {
53-
if (isFollowEnabled()) {
54-
this.scrollTop = this.scrollHeight
35+
const isFollowEnabled = () => JSON.parse(logPanel.dataset.follow) === true
36+
const toggleFollow = () => {
37+
logPanel.dataset.follow = !isFollowEnabled()
38+
followButton.classList.toggle("btn-primary")
39+
followButton.classList.toggle("btn-outline-primary")
40+
checkbox.classList.toggle("mdi-checkbox-marked")
41+
checkbox.classList.toggle("mdi-checkbox-blank-outline")
5542
}
56-
})
5743

58-
followButton.addEventListener("click", function() {
59-
toggleFollow()
44+
logPanel.addEventListener("htmx:afterSwap", function () {
45+
if (isFollowEnabled()) {
46+
this.scrollTop = this.scrollHeight
47+
}
48+
})
49+
50+
followButton.addEventListener("click", function () {
51+
toggleFollow()
52+
})
6053
})
61-
})
6254
</script>
6355
{% endblock %}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# pylint: disable=R0801
2+
"""Container Test Case"""
3+
4+
import requests_mock
5+
from django.urls import reverse
6+
from django.contrib.contenttypes.models import ContentType
7+
from django.core.serializers.json import DjangoJSONEncoder
8+
from rest_framework import status
9+
from users.models import ObjectPermission
10+
from netbox_docker_plugin.models.container import Container
11+
from netbox_docker_plugin.models.host import Host
12+
from netbox_docker_plugin.models.image import Image
13+
from netbox_docker_plugin.models.registry import Registry
14+
from netbox_docker_plugin.tests.base import BaseAPITestCase
15+
16+
17+
class ContainerApiExecTestCase(BaseAPITestCase):
18+
"""Container Exec Test Case Class"""
19+
20+
model = Container
21+
22+
def setUp(self):
23+
super().setUp()
24+
25+
host1 = Host.objects.create(endpoint="http://localhost:8080", name="host1")
26+
27+
registry1 = Registry.objects.create(
28+
host=host1, name="registry1", serveraddress="http://localhost:8080"
29+
)
30+
31+
image1 = Image.objects.create(host=host1, name="image1", registry=registry1)
32+
33+
container = Container.objects.create(
34+
host=host1,
35+
image=image1,
36+
name="container1",
37+
operation="none",
38+
state="created",
39+
ContainerID="1234",
40+
)
41+
42+
# Add object-level permission
43+
obj_perm = ObjectPermission(
44+
name="Test permission",
45+
constraints={"pk": container.pk},
46+
actions=["add"],
47+
)
48+
obj_perm.save()
49+
# pylint: disable=E1101
50+
obj_perm.users.add(self.user)
51+
# pylint: disable=E1101
52+
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
53+
54+
self.endpoint = reverse(
55+
viewname=f"plugins-api:{self._get_view_namespace()}:container-exec",
56+
kwargs={"pk": container.pk},
57+
)
58+
59+
def test_that_exec_endpoint_works(self):
60+
"""Test Exec endpoint"""
61+
62+
with requests_mock.Mocker(json_encoder=DjangoJSONEncoder) as m:
63+
m.put(
64+
"http://localhost:8080/api/engine/containers/1234/exec",
65+
json={"stdout": "..."},
66+
)
67+
68+
response = self.client.post(
69+
self.endpoint, **self.header, data={"cmd": ["ls"]}, format="json"
70+
)
71+
self.assertHttpStatus(response, status.HTTP_200_OK)
72+
self.assertEqual(response.data, {"stdout": "..."})
73+
74+
def test_that_exec_endpoint_with_invalid_command_fail(self):
75+
"""Test exec endpoinr with invalid command"""
76+
77+
response = self.client.post(
78+
self.endpoint, **self.header, data={"command": ["ls"]}, format="json"
79+
)
80+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
81+
82+
def test_that_exec_endpoint_fail_with_backend_error(self):
83+
"""Test Exec endpoint"""
84+
85+
with requests_mock.Mocker(json_encoder=DjangoJSONEncoder) as m:
86+
m.put(
87+
"http://localhost:8080/api/engine/containers/1234/exec",
88+
text="Error",
89+
status_code= 500
90+
)
91+
92+
response = self.client.post(
93+
self.endpoint, **self.header, data={"cmd": ["ls"]}, format="json"
94+
)
95+
self.assertHttpStatus(response, status.HTTP_502_BAD_GATEWAY)
96+
self.assertEqual(response.data, 'Error')

0 commit comments

Comments
 (0)