diff --git a/netbox_docker_plugin/__init__.py b/netbox_docker_plugin/__init__.py index d7f68e6..337c7f0 100644 --- a/netbox_docker_plugin/__init__.py +++ b/netbox_docker_plugin/__init__.py @@ -11,7 +11,7 @@ class NetBoxDockerConfig(PluginConfig): name = "netbox_docker_plugin" verbose_name = " NetBox Docker Plugin" description = "Manage Docker" - version = "5.1.1" + version = "5.2.0" base_url = "docker" min_version = "4.5.0" author = "Vincent Simonin , David Delassus " diff --git a/netbox_docker_plugin/api/serializers.py b/netbox_docker_plugin/api/serializers.py index 2c310df..403bded 100644 --- a/netbox_docker_plugin/api/serializers.py +++ b/netbox_docker_plugin/api/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from utilities.query import dict_to_filter_params from users.models import Token +from virtualization.api.serializers import VirtualMachineSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from ..models.host import Host from ..models.image import Image @@ -693,6 +694,7 @@ class HostSerializer(NetBoxModelSerializer): containers = NestedContainerSerializer(many=True, read_only=True) registries = NestedRegistrySerializer(many=True, read_only=True) token = NestedTokenSerializer(read_only=True) + virtual_machine = VirtualMachineSerializer(required=False) class Meta: """Host Serializer Meta class""" @@ -706,6 +708,7 @@ class Meta: "name", "state", "token", + "virtual_machine", "netbox_base_url", "agent_version", "docker_api_version", diff --git a/netbox_docker_plugin/api/views.py b/netbox_docker_plugin/api/views.py index b140d75..1885fec 100644 --- a/netbox_docker_plugin/api/views.py +++ b/netbox_docker_plugin/api/views.py @@ -39,7 +39,13 @@ class HostViewSet(NetBoxModelViewSet): """Host view set class""" queryset = Host.objects.prefetch_related( - "images", "volumes", "networks", "containers", "registries", "tags" + "images", + "volumes", + "networks", + "containers", + "registries", + "virtual_machine", + "tags", ) filterset_class = filtersets.HostFilterSet serializer_class = HostSerializer @@ -108,7 +114,7 @@ def force_pull(self, _request, **_kwargs): url, timeout=10, data=json.dumps({"data": data}, cls=DjangoJSONEncoder), - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) resp.raise_for_status() diff --git a/netbox_docker_plugin/filtersets.py b/netbox_docker_plugin/filtersets.py index a51f3a0..8674cd2 100644 --- a/netbox_docker_plugin/filtersets.py +++ b/netbox_docker_plugin/filtersets.py @@ -38,6 +38,7 @@ class Meta: "state", "agent_version", "docker_api_version", + "virtual_machine", ) # pylint: disable=W0613 diff --git a/netbox_docker_plugin/forms/host.py b/netbox_docker_plugin/forms/host.py index b1fe546..75e08d8 100644 --- a/netbox_docker_plugin/forms/host.py +++ b/netbox_docker_plugin/forms/host.py @@ -2,7 +2,8 @@ from django import forms from utilities.forms.rendering import FieldSet -from utilities.forms.fields import TagFilterField +from utilities.forms.fields import TagFilterField, DynamicModelChoiceField +from virtualization.models import VirtualMachine from netbox.forms import ( NetBoxModelForm, NetBoxModelImportForm, @@ -12,9 +13,43 @@ from ..models.host import Host, HostStateChoices +class HostAddForm(NetBoxModelForm): + """Host form definition class""" + + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + selector=True, + required=False, + label="Virtual Machine", + ) + + class Meta: + """Host form definition Meta class""" + + model = Host + fields = ( + "name", + "endpoint", + "virtual_machine", + ) + help_texts = {"name": "Unique Name", "endpoint": "Docker instance endpoint"} + labels = {"name": "Name", "endpoint": "Endpoint"} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields.pop("tags", None) + + class HostForm(NetBoxModelForm): """Host form definition class""" + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + selector=True, + required=False, + label="Virtual Machine", + ) + class Meta: """Host form definition Meta class""" @@ -22,6 +57,7 @@ class Meta: fields = ( "name", "endpoint", + "virtual_machine", "tags", ) help_texts = {"name": "Unique Name", "endpoint": "Docker instance endpoint"} diff --git a/netbox_docker_plugin/migrations/1044_host_virtual_machine.py b/netbox_docker_plugin/migrations/1044_host_virtual_machine.py new file mode 100644 index 0000000..33e6968 --- /dev/null +++ b/netbox_docker_plugin/migrations/1044_host_virtual_machine.py @@ -0,0 +1,29 @@ +# pylint: disable=C0103 +"""Migration file""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration file""" + + dependencies = [ + ( + "netbox_docker_plugin", + "1043_container_cap_drop_container_extra_hosts_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="host", + name="virtual_machine", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="virtualization.virtualmachine", + ), + ), + ] diff --git a/netbox_docker_plugin/models/container.py b/netbox_docker_plugin/models/container.py index 61a1fbf..c8be44b 100644 --- a/netbox_docker_plugin/models/container.py +++ b/netbox_docker_plugin/models/container.py @@ -328,6 +328,7 @@ def get_absolute_url(self): return reverse("plugins:netbox_docker_plugin:container", args=[self.pk]) def clean(self): + """Validate that the image belongs to the same host and has been pulled.""" super().clean() if self.host != self.image.host: @@ -335,6 +336,11 @@ def clean(self): {"image": f"Image {self.image} does not belong to host {self.host}."} ) + if not self.image.ImageID: + raise ValidationError( + {"image": f"Image {self.image} has no ID yet. Pull or refresh it first."} + ) + class Port(models.Model): """Container definition class""" diff --git a/netbox_docker_plugin/models/host.py b/netbox_docker_plugin/models/host.py index d1d8993..2a1d50b 100644 --- a/netbox_docker_plugin/models/host.py +++ b/netbox_docker_plugin/models/host.py @@ -9,6 +9,7 @@ ) from utilities.choices import ChoiceSet from users.models import Token +from virtualization.models import VirtualMachine from netbox.models import NetBoxModel @@ -64,6 +65,12 @@ class Host(NetBoxModel): default=HostStateChoices.STATE_CREATED, ) token = models.ForeignKey(Token, on_delete=models.SET_NULL, null=True, blank=True) + virtual_machine = models.OneToOneField( + VirtualMachine, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) netbox_base_url = models.CharField( max_length=1024, validators=[URLValidator()], diff --git a/netbox_docker_plugin/tables.py b/netbox_docker_plugin/tables.py index ea1a419..161cd0f 100644 --- a/netbox_docker_plugin/tables.py +++ b/netbox_docker_plugin/tables.py @@ -26,6 +26,7 @@ class HostTable(NetBoxTable): """Host Table definition class""" name = tables.Column(linkify=True) + virtual_machine = tables.Column(linkify=True, verbose_name="Virtual Machine") image_count = columns.LinkedCountColumn( viewname="plugins:netbox_docker_plugin:image_list", url_params={"host_id": "pk"}, @@ -62,6 +63,7 @@ class Meta(NetBoxTable.Meta): "name", "endpoint", "state", + "virtual_machine", "agent_version", "docker_api_version", "image_count", diff --git a/netbox_docker_plugin/template_content.py b/netbox_docker_plugin/template_content.py new file mode 100644 index 0000000..b148119 --- /dev/null +++ b/netbox_docker_plugin/template_content.py @@ -0,0 +1,23 @@ +"""Template modifications definitions""" + +# pylint: disable=W0223 + +from netbox.plugins import PluginTemplateExtension +from .models import Host + + +class VirtualMachineHostTable(PluginTemplateExtension): + """Virtual machine object template""" + + models = ["virtualization.virtualmachine"] + + def right_page(self): + return self.render( + "netbox_docker_plugin/virtual_machine_host_table.html", + extra_context={ + "Host": Host.objects.filter(virtual_machine=self.context["object"]) + }, + ) + + +template_extensions = [VirtualMachineHostTable] diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/host.html b/netbox_docker_plugin/templates/netbox_docker_plugin/host.html index 244ae0d..9eaafd6 100644 --- a/netbox_docker_plugin/templates/netbox_docker_plugin/host.html +++ b/netbox_docker_plugin/templates/netbox_docker_plugin/host.html @@ -27,6 +27,17 @@

Host

Endpoint {{ object.endpoint|remove_password }} + + Virtual Machine + + {% if object.virtual_machine %} + + {{ object.virtual_machine|placeholder }} + {% else %} + {{ object.virtual_machine|placeholder }} + {% endif %} + + State {{ object.get_state_display }} diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/virtual_machine_host_table.html b/netbox_docker_plugin/templates/netbox_docker_plugin/virtual_machine_host_table.html new file mode 100644 index 0000000..d17b6c5 --- /dev/null +++ b/netbox_docker_plugin/templates/netbox_docker_plugin/virtual_machine_host_table.html @@ -0,0 +1,4 @@ +
+

Docker Host

+ {% htmx_table 'plugins:netbox_docker_plugin:host_list' virtual_machine=object.pk %} +
diff --git a/netbox_docker_plugin/tests/container/test_container_api.py b/netbox_docker_plugin/tests/container/test_container_api.py index f8d7112..fd268d9 100644 --- a/netbox_docker_plugin/tests/container/test_container_api.py +++ b/netbox_docker_plugin/tests/container/test_container_api.py @@ -70,8 +70,18 @@ def setUpTestData(cls) -> None: host=host2, name="registry2", serveraddress="http://localhost:8082" ) - image1 = Image.objects.create(host=host1, name="image1", registry=registry1) - image2 = Image.objects.create(host=host2, name="image2", registry=registry2) + image1 = Image.objects.create( + host=host1, + name="image1", + registry=registry1, + ImageID="sha256:abc123", + ) + image2 = Image.objects.create( + host=host2, + name="image2", + registry=registry2, + ImageID="sha256:abc456", + ) network1 = Network.objects.create(host=host1, name="network1") network2 = Network.objects.create(host=host2, name="network2") @@ -244,6 +254,7 @@ def test_that_patch_overwrites_data_only_when_explicitly_set(self): host=host3, name="image3", registry=registry3, + ImageID="sha256:abc789", ) container11 = Container.objects.create( host=host3, @@ -330,8 +341,18 @@ def test_that_container_host_cannot_be_changed(self): host=host2, name="registry5", serveraddress="http://localhost:8082" ) - image1 = Image.objects.create(host=host1, name="image", registry=registry1) - image2 = Image.objects.create(host=host2, name="image", registry=registry2) + image1 = Image.objects.create( + host=host1, + name="image", + registry=registry1, + ImageID="sha256:abc101112", + ) + image2 = Image.objects.create( + host=host2, + name="image", + registry=registry2, + ImageID="sha256:abc131415", + ) container = Container.objects.create(host=host1, image=image1, name="container") diff --git a/netbox_docker_plugin/tests/container/test_container_api_exec.py b/netbox_docker_plugin/tests/container/test_container_api_exec.py index e1b7fcb..92df7fc 100644 --- a/netbox_docker_plugin/tests/container/test_container_api_exec.py +++ b/netbox_docker_plugin/tests/container/test_container_api_exec.py @@ -28,7 +28,12 @@ def setUp(self): host=host1, name="registry1", serveraddress="http://localhost:8080" ) - image1 = Image.objects.create(host=host1, name="image1", registry=registry1) + image1 = Image.objects.create( + host=host1, + name="image1", + registry=registry1, + ImageID="sha256:abc123", + ) container = Container.objects.create( host=host1, @@ -86,11 +91,11 @@ def test_that_exec_endpoint_fail_with_backend_error(self): m.put( "http://localhost:8080/api/engine/containers/1234/exec", text="Error", - status_code= 500 + status_code=500, ) response = self.client.post( self.endpoint, **self.header, data={"cmd": ["ls"]}, format="json" ) self.assertHttpStatus(response, status.HTTP_502_BAD_GATEWAY) - self.assertEqual(response.data, 'Error') + self.assertEqual(response.data, "Error") diff --git a/netbox_docker_plugin/tests/container/test_container_operation_view.py b/netbox_docker_plugin/tests/container/test_container_operation_view.py index 8af8498..b0cd7ab 100644 --- a/netbox_docker_plugin/tests/container/test_container_operation_view.py +++ b/netbox_docker_plugin/tests/container/test_container_operation_view.py @@ -257,8 +257,12 @@ def setUpTestData(cls): host=host2, name="registry2", serveraddress="http://localhost:8082" ) - image1 = Image.objects.create(host=host1, name="image1", registry=registry1) - image2 = Image.objects.create(host=host2, name="image2", registry=registry2) + image1 = Image.objects.create( + host=host1, name="image1", registry=registry1, ImageID="sha256:abc123" + ) + image2 = Image.objects.create( + host=host2, name="image2", registry=registry2, ImageID="sha256:abc456" + ) Container.objects.create( host=host1, diff --git a/netbox_docker_plugin/tests/container/test_container_validation.py b/netbox_docker_plugin/tests/container/test_container_validation.py index 450ff12..7201ef7 100644 --- a/netbox_docker_plugin/tests/container/test_container_validation.py +++ b/netbox_docker_plugin/tests/container/test_container_validation.py @@ -39,6 +39,36 @@ def test_that_container_is_created(self): self.assertTrue(isinstance(container.id, int)) + def test_that_container_image_must_have_an_id(self): + """Test that the image must have been pulled (non-empty ImageID)""" + + with self.assertRaises(ValidationError) as cm: + container = Container( + host=self.objects["host1"], + image=self.objects["image1"], + name="container_no_image_id", + operation="none", + state="created", + ) + container.clean() + + self.assertEqual( + cm.exception.message_dict["image"], + ["Image image1:latest has no ID yet. Pull or refresh it first."], + ) + + def test_that_container_image_with_id_passes_validation(self): + """Test that an image with a non-empty ImageID passes validation""" + + container = Container( + host=self.objects["host1"], + image=self.objects["image1_pulled"], + name="container_with_image_id", + operation="none", + state="created", + ) + container.clean() # must not raise + def test_that_container_image_must_be_on_the_same_host(self): """Test that Container image must be on the same host""" @@ -138,6 +168,12 @@ def setUpTestData(cls) -> None: cls.objects["image1"] = Image.objects.create( host=cls.objects["host1"], name="image1", registry=cls.objects["registry1"] ) + cls.objects["image1_pulled"] = Image.objects.create( + host=cls.objects["host1"], + name="image1_pulled", + registry=cls.objects["registry1"], + ImageID="sha256:abc123", + ) cls.objects["image2"] = Image.objects.create( host=cls.objects["host2"], name="image2", registry=cls.objects["registry2"] ) diff --git a/netbox_docker_plugin/tests/container/test_container_views.py b/netbox_docker_plugin/tests/container/test_container_views.py index 2527a00..2575b6a 100644 --- a/netbox_docker_plugin/tests/container/test_container_views.py +++ b/netbox_docker_plugin/tests/container/test_container_views.py @@ -28,8 +28,12 @@ def setUpTestData(cls): host=host2, name="registry2", serveraddress="http://localhost:8082" ) - image1 = Image.objects.create(host=host1, name="image1", registry=registry1) - image2 = Image.objects.create(host=host2, name="image2", registry=registry2) + image1 = Image.objects.create( + host=host1, name="image1", registry=registry1, ImageID="sha256:abc123" + ) + image2 = Image.objects.create( + host=host2, name="image2", registry=registry2, ImageID="sha256:abc456" + ) container1 = Container.objects.create( host=host1, diff --git a/netbox_docker_plugin/tests/host/test_host_views.py b/netbox_docker_plugin/tests/host/test_host_views.py index 12fc735..2f617e5 100644 --- a/netbox_docker_plugin/tests/host/test_host_views.py +++ b/netbox_docker_plugin/tests/host/test_host_views.py @@ -26,6 +26,7 @@ class HostViewsTestCase(BaseModelViewTestCase, ViewTestCases.PrimaryObjectViewTe "host7,http://localhost:8084", ) bulk_edit_data = {"endpoint": "http://localhost:8083"} + validation_excluded_fields = ["tags"] def setUp(self): super().setUp() @@ -91,8 +92,7 @@ def test_host_error_state(self): """Test the host error state""" self.assertEqual( - self.objects["host_error_state"].state, - HostStateChoices.STATE_ERROR + self.objects["host_error_state"].state, HostStateChoices.STATE_ERROR ) @classmethod diff --git a/netbox_docker_plugin/tests/host/test_host_virtual_machine.py b/netbox_docker_plugin/tests/host/test_host_virtual_machine.py new file mode 100644 index 0000000..cddb007 --- /dev/null +++ b/netbox_docker_plugin/tests/host/test_host_virtual_machine.py @@ -0,0 +1,74 @@ +"""Host ↔ VirtualMachine relationship tests""" + +from django.db import IntegrityError, transaction +from django.test import TestCase + +from virtualization.models import VirtualMachine + +from netbox_docker_plugin.models.host import Host + + +class HostVirtualMachineTestCase(TestCase): + """Test the optional OneToOne link between Host and VirtualMachine.""" + + @classmethod + def setUpTestData(cls): + cls.vm1 = VirtualMachine.objects.create(name="vm1") + cls.vm2 = VirtualMachine.objects.create(name="vm2") + cls.host = Host.objects.create( + endpoint="http://localhost:8080", + name="host1", + ) + + def test_host_without_virtual_machine(self): + """A Host with no VirtualMachine linked is valid.""" + host = Host.objects.create(endpoint="http://localhost:8081", name="host2") + self.assertIsNone(host.virtual_machine) + + def test_host_with_virtual_machine(self): + """A Host can be linked to a VirtualMachine.""" + self.host.virtual_machine = self.vm1 + self.host.save() + self.host.refresh_from_db() + self.assertEqual(self.host.virtual_machine, self.vm1) + + def test_one_to_one_constraint(self): + """Two Hosts cannot share the same VirtualMachine.""" + Host.objects.create( + endpoint="http://localhost:8082", + name="host_a", + virtual_machine=self.vm2, + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Host.objects.create( + endpoint="http://localhost:8083", + name="host_b", + virtual_machine=self.vm2, + ) + + def test_virtual_machine_set_null_on_delete(self): + """Deleting the VirtualMachine sets the Host FK to NULL (SET_NULL).""" + vm = VirtualMachine.objects.create(name="vm_to_delete") + host = Host.objects.create( + endpoint="http://localhost:8084", + name="host_set_null", + virtual_machine=vm, + ) + vm.delete() + host.refresh_from_db() + self.assertIsNone(host.virtual_machine) + + def test_unlink_virtual_machine(self): + """Setting virtual_machine to None removes the link without deleting either object.""" + vm = VirtualMachine.objects.create(name="vm_unlink") + host = Host.objects.create( + endpoint="http://localhost:8085", + name="host_unlink", + virtual_machine=vm, + ) + host.virtual_machine = None + host.save() + host.refresh_from_db() + self.assertIsNone(host.virtual_machine) + self.assertTrue(VirtualMachine.objects.filter(pk=vm.pk).exists()) diff --git a/netbox_docker_plugin/tests/volume/test_volume_api.py b/netbox_docker_plugin/tests/volume/test_volume_api.py index ead1095..3336e6b 100644 --- a/netbox_docker_plugin/tests/volume/test_volume_api.py +++ b/netbox_docker_plugin/tests/volume/test_volume_api.py @@ -72,7 +72,9 @@ def test_that_embedded_anonymous_volume_is_created(self): host=host, name="registry8", serveraddress="http://localhost:8089" ) - image = Image.objects.create(host=host, name="image8", registry=registry) + image = Image.objects.create( + host=host, name="image8", registry=registry, ImageID="sha256:abc123" + ) container = Container.objects.create(host=host, image=image, name="container8") diff --git a/netbox_docker_plugin/urls.py b/netbox_docker_plugin/urls.py index 3475560..84f6530 100644 --- a/netbox_docker_plugin/urls.py +++ b/netbox_docker_plugin/urls.py @@ -29,7 +29,7 @@ urlpatterns = ( # Host path("hosts/", host_views.HostListView.as_view(), name="host_list"), - path("hosts/add/", host_views.HostEditView.as_view(), name="host_add"), + path("hosts/add/", host_views.HostAddView.as_view(), name="host_add"), path( "hosts/import/", host_views.HostBulkImportView.as_view(), diff --git a/netbox_docker_plugin/views/host.py b/netbox_docker_plugin/views/host.py index 69b84c5..24958be 100644 --- a/netbox_docker_plugin/views/host.py +++ b/netbox_docker_plugin/views/host.py @@ -19,7 +19,7 @@ class HostView(GetRelatedModelsMixin, generic.ObjectView): """Host view definition""" queryset = Host.objects.prefetch_related( - "images", "volumes", "networks", "containers", "registries" + "images", "volumes", "networks", "containers", "registries", "virtual_machine" ) def get_extra_context(self, request, instance): @@ -57,15 +57,17 @@ class HostListView(generic.ObjectListView): filterset_form = host.HostFilterForm -class HostEditView(generic.ObjectEditView): - """Host edition view definition""" +class HostAddView(generic.ObjectEditView): + """Host add view definition""" queryset = Host.objects.all() - form = host.HostForm + form = host.HostAddForm def alter_object(self, obj, request, url_args, url_kwargs): - if request.method == "POST" and not "pk" in url_kwargs: - token = Token(user=self.request.user, write_enabled=True) + if request.method == "POST": + token = Token( + user=self.request.user, write_enabled=True, description="DockerAgent" + ) token.save() obj.token = token @@ -74,6 +76,13 @@ def alter_object(self, obj, request, url_args, url_kwargs): return super().alter_object(obj, request, url_args, url_kwargs) +class HostEditView(generic.ObjectEditView): + """Host edition view definition""" + + queryset = Host.objects.all() + form = host.HostForm + + class HostBulkEditView(generic.BulkEditView): """Host bulk edition view definition""" diff --git a/pyproject.toml b/pyproject.toml index 616471d..a084941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-docker-plugin" -version = "5.1.1" +version = "5.2.0" authors = [ { name="Vincent Simonin", email="vincent@saashup.com" }, { name="David Delassus", email="david.jose.delassus@gmail.com" }