diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 739aa1a..b2db6eb 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -12,7 +12,7 @@ jobs: max-parallel: 4 matrix: python-version: ["3.12"] - netbox-version: ["v4.3.7"] + netbox-version: ["v4.4.6"] services: redis: image: redis diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index ef2f438..6585d3f 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -15,7 +15,7 @@ jobs: max-parallel: 4 matrix: python-version: ["3.12"] - netbox-version: ["v4.3.7"] + netbox-version: ["v4.4.6"] services: redis: image: redis diff --git a/netbox_docker_plugin/__init__.py b/netbox_docker_plugin/__init__.py index d968f97..41010aa 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 = "4.8.0" + version = "4.9.0" base_url = "docker" min_version = "4.3.7" author = "Vincent Simonin , David Delassus " diff --git a/netbox_docker_plugin/api/serializers.py b/netbox_docker_plugin/api/serializers.py index e5e10d4..18f3748 100644 --- a/netbox_docker_plugin/api/serializers.py +++ b/netbox_docker_plugin/api/serializers.py @@ -20,6 +20,7 @@ NetworkSetting, Device, LogDriverOption, + Sysctl, ) from ..models.registry import Registry @@ -422,6 +423,19 @@ class Meta: ) +class SysctlSerializer(serializers.ModelSerializer): + """Container sysctl Serializer class""" + + class Meta: + """Container sysctl Serializer Meta class""" + + model = Sysctl + fields = ( + "key", + "value", + ) + + class ContainerSerializer(NetBoxModelSerializer): """Container Serializer class""" @@ -438,6 +452,7 @@ class ContainerSerializer(NetBoxModelSerializer): network_settings = NetworkSettingSerializer(many=True, required=False) devices = DeviceSerializer(many=True, required=False) log_driver_options = LogDriverOptionSerializer(many=True, required=False) + sysctls = SysctlSerializer(many=True, required=False) class Meta: """Container Serializer Meta class""" @@ -467,6 +482,7 @@ class Meta: "devices", "log_driver", "log_driver_options", + "sysctls", "custom_fields", "created", "last_updated", @@ -484,6 +500,7 @@ def validate(self, data): attrs.pop("binds", None) attrs.pop("network_settings", None) attrs.pop("devices", None) + attrs.pop("sysctls", None) super().validate(attrs) @@ -499,6 +516,7 @@ def create(self, validated_data): binds_data = validated_data.pop("binds", None) network_settings_data = validated_data.pop("network_settings", None) devices_data = validated_data.pop("devices", None) + sysctls_data = validated_data.pop("sysctls", None) container = super().create(validated_data) @@ -542,6 +560,10 @@ def create(self, validated_data): obj.full_clean() obj.save() + if sysctls_data is not None: + for sysctl in sysctls_data: + Sysctl.objects.create(container=container, **sysctl) + return container # pylint: disable=R0912 @@ -554,6 +576,7 @@ def update(self, instance, validated_data): binds_data = validated_data.pop("binds", None) network_settings_data = validated_data.pop("network_settings", None) devices_data = validated_data.pop("devices", None) + sysctls_data = validated_data.pop("sysctls", None) container = super().update(instance, validated_data) @@ -605,6 +628,11 @@ def update(self, instance, validated_data): obj.full_clean() obj.save() + if sysctls_data is not None: + Sysctl.objects.filter(container=container).delete() + for sysctl in sysctls_data: + Sysctl.objects.create(container=container, **sysctl) + return container diff --git a/netbox_docker_plugin/api/views.py b/netbox_docker_plugin/api/views.py index 3fc46c6..549f5a9 100644 --- a/netbox_docker_plugin/api/views.py +++ b/netbox_docker_plugin/api/views.py @@ -47,13 +47,19 @@ class HostViewSet(NetBoxModelViewSet): def perform_create(self, serializer): if isinstance(serializer.validated_data, Sequence): for obj in serializer.validated_data: - token = Token(user=self.request.user, write_enabled=True, description="DockerAgent") + token = Token( + user=self.request.user, + write_enabled=True, + description="DockerAgent", + ) token.save() obj["token"] = token obj["netbox_base_url"] = self.request.stream.META["HTTP_ORIGIN"] else: - token = Token(user=self.request.user, write_enabled=True, description="DockerAgent") + token = Token( + user=self.request.user, write_enabled=True, description="DockerAgent" + ) token.save() serializer.validated_data["token"] = token @@ -85,7 +91,7 @@ class ImageViewSet(NetBoxModelViewSet): renderer_classes=[JSONRenderer], ) def force_pull(self, _request, **_kwargs): - """ Force pull an existing image """ + """Force pull an existing image""" image: Image = self.get_object() agent_url = image.host.endpoint @@ -146,6 +152,7 @@ class ContainerViewSet(NetBoxModelViewSet): "ports", "labels", "log_driver_options", + "sysctls", "tags", ) filterset_class = filtersets.ContainerFilterSet diff --git a/netbox_docker_plugin/filtersets.py b/netbox_docker_plugin/filtersets.py index be08133..a51f3a0 100644 --- a/netbox_docker_plugin/filtersets.py +++ b/netbox_docker_plugin/filtersets.py @@ -17,6 +17,7 @@ NetworkSetting, Device, LogDriverOption, + Sysctl, ) from .models.registry import Registry @@ -340,3 +341,22 @@ class Meta: model = Device fields = ("id",) + + +class SysctlFilterSet(BaseFilterSet): + """Sysctl filterset definition class""" + + container_id = ModelMultipleChoiceFilter( + field_name="container_id", + queryset=Container.objects.all(), + label="Container (ID)", + ) + + class Meta: + """Sysctl filterset definition meta class""" + + model = Sysctl + fields = ( + "id", + "key", + ) diff --git a/netbox_docker_plugin/forms/sysctl.py b/netbox_docker_plugin/forms/sysctl.py new file mode 100644 index 0000000..2c3edbb --- /dev/null +++ b/netbox_docker_plugin/forms/sysctl.py @@ -0,0 +1,28 @@ +"""Sysctl Form definition""" + +from django import forms +from utilities.forms.fields import DynamicModelChoiceField +from ..models.container import Sysctl, Container + + +class SysctlForm(forms.ModelForm): + """Sysctl form definition class""" + + container = DynamicModelChoiceField( + label="Container", queryset=Container.objects.all(), required=True + ) + + class Meta: + """Sysctl form definition Meta class""" + + model = Sysctl + fields = ( + "container", + "key", + "value", + ) + labels = { + "container": "Container", + "key": "Key", + "value": "Value", + } diff --git a/netbox_docker_plugin/migrations/1041_sysctl.py b/netbox_docker_plugin/migrations/1041_sysctl.py new file mode 100644 index 0000000..165e4b2 --- /dev/null +++ b/netbox_docker_plugin/migrations/1041_sysctl.py @@ -0,0 +1,67 @@ +# pylint: disable=C0103 +# pylint: disable=R0801 +"""Migration file""" + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration file""" + + dependencies = [ + ("netbox_docker_plugin", "1040_alter_container_log_driver"), + ] + + operations = [ + migrations.CreateModel( + name="Sysctl", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ( + "key", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(limit_value=1), + django.core.validators.MaxLengthValidator(limit_value=255), + ], + ), + ), + ( + "value", + models.CharField( + blank=True, + max_length=4095, + validators=[ + django.core.validators.MaxLengthValidator(limit_value=4095) + ], + ), + ), + ( + "container", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sysctls", + to="netbox_docker_plugin.container", + ), + ), + ], + options={ + "ordering": ("container", "key"), + "constraints": [ + models.UniqueConstraint( + models.F("key"), + models.F("container"), + name="netbox_docker_plugin_sysctl_unique_key_container'", + ) + ], + }, + ), + ] diff --git a/netbox_docker_plugin/models/container.py b/netbox_docker_plugin/models/container.py index 8156cce..357f245 100644 --- a/netbox_docker_plugin/models/container.py +++ b/netbox_docker_plugin/models/container.py @@ -175,9 +175,7 @@ class Container(NetBoxModel): blank=True, ) cmd = ArrayField( - models.CharField( - max_length=1024, blank=True, null=True - ), + models.CharField(max_length=1024, blank=True, null=True), null=True, blank=True, ) @@ -585,3 +583,42 @@ class Meta: def __str__(self): return f"{self.host_path}:{self.container_path}" + + +class Sysctl(models.Model): + """SysCtl definition class""" + + objects = RestrictedQuerySet.as_manager() + + container = models.ForeignKey( + Container, on_delete=models.CASCADE, related_name="sysctls" + ) + key = models.CharField( + max_length=255, + validators=[ + MinLengthValidator(limit_value=1), + MaxLengthValidator(limit_value=255), + ], + ) + value = models.CharField( + max_length=4095, + validators=[ + MaxLengthValidator(limit_value=4095), + ], + blank=True, + ) + + class Meta: + """SysCtl Model Meta Class""" + + ordering = ("container", "key") + constraints = ( + models.UniqueConstraint( + "key", + "container", + name="%(app_label)s_%(class)s_unique_key_container'", + ), + ) + + def __str__(self): + return f"{self.key}" diff --git a/netbox_docker_plugin/tables.py b/netbox_docker_plugin/tables.py index 860d87a..b421da6 100644 --- a/netbox_docker_plugin/tables.py +++ b/netbox_docker_plugin/tables.py @@ -16,6 +16,7 @@ NetworkSetting, Device, LogDriverOption, + Sysctl, ) from .models.registry import Registry from .templatetags.host import remove_password @@ -461,3 +462,18 @@ class Meta(NetBoxTable.Meta): "host_path", "container_path", ) + + +class SysctlTable(NetBoxTable): + """Sysctl Table definition class""" + + container = tables.Column(linkify=True) + + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta(NetBoxTable.Meta): + """Sysctl Table definition Meta class""" + + model = Sysctl + fields = ("container", "key", "value") + default_columns = ("container", "key", "value") diff --git a/netbox_docker_plugin/templates/netbox_docker_plugin/container.html b/netbox_docker_plugin/templates/netbox_docker_plugin/container.html index 843bce1..4219066 100644 --- a/netbox_docker_plugin/templates/netbox_docker_plugin/container.html +++ b/netbox_docker_plugin/templates/netbox_docker_plugin/container.html @@ -67,11 +67,12 @@

Container

Label {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:label_list' container_id=object.pk %} @@ -85,11 +86,12 @@

Environment Variables {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:env_list' container_id=object.pk %} @@ -103,11 +105,12 @@

Port Mappings {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:port_list' container_id=object.pk %} @@ -119,11 +122,12 @@

Network Settings {% if perms.netbox_docker_plugin.add_network_setting %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:networksetting_list' container_id=object.pk %} @@ -137,11 +141,12 @@

Mount {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:mount_list' container_id=object.pk %} @@ -152,11 +157,12 @@

Binds {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:bind_list' container_id=object.pk %} @@ -170,11 +176,12 @@

Devices {% if perms.netbox_docker_plugin.add_device %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:device_list' container_id=object.pk %} @@ -185,15 +192,32 @@

Log Driver Options {% if perms.netbox_docker_plugin.add_env %} - + {% endif %}

{% htmx_table 'plugins:netbox_docker_plugin:logdriveroption_list' container_id=object.pk %} +
+
+

+ Sysctl + {% if perms.netbox_docker_plugin.add_device %} + + {% endif %} +

+ {% htmx_table 'plugins:netbox_docker_plugin:sysctl_list' container_id=object.pk %} +
+
{% endblock content %} diff --git a/netbox_docker_plugin/tests/container/test_container_api.py b/netbox_docker_plugin/tests/container/test_container_api.py index 7e87876..6e6d1f4 100644 --- a/netbox_docker_plugin/tests/container/test_container_api.py +++ b/netbox_docker_plugin/tests/container/test_container_api.py @@ -51,6 +51,7 @@ class ContainerApiTestCase( "network_settings", "devices", "log_driver_options", + "sysctls", ] @classmethod @@ -96,7 +97,7 @@ def setUpTestData(cls) -> None: name="container3", operation="none", state="created", - cmd=["ls", "-al"] + cmd=["ls", "-al"], ) cls.create_data = [ @@ -135,6 +136,10 @@ def setUpTestData(cls) -> None: "log_driver_options": [ {"option_name": "syslog-address", "value": "udp://127.0.0.1:514"}, ], + "sysctls": [ + {"key": "net.ipv4.ip_forward", "value": "1"}, + {"key": "kernel.panic", "value": "5"}, + ], }, { "host": host2.pk, @@ -159,6 +164,7 @@ def setUpTestData(cls) -> None: "ports": [], "env": [], "labels": [], + "sysctls": [], "mounts": [ {"source": "/data", "volume": volume3.pk}, {"source": "/etc", "volume": volume3.pk}, @@ -172,6 +178,7 @@ def setUpTestData(cls) -> None: "ports": [], "env": [], "labels": [], + "sysctls": [], "mounts": [ {"source": "/data", "volume": volume3.pk}, {"source": "/etc", "volume": volume3.pk}, @@ -184,8 +191,9 @@ def setUpTestData(cls) -> None: "ports": [], "env": [{"var_name": "ENV", "value": ""}], "labels": [], + "sysctls": [], "cap_add": ["NET_ADMIN"], - "cmd": ["cat", "/etc/hosts"] + "cmd": ["cat", "/etc/hosts"], }, ] diff --git a/netbox_docker_plugin/tests/host/test_host_api.py b/netbox_docker_plugin/tests/host/test_host_api.py index aa6fdb5..948d7bf 100644 --- a/netbox_docker_plugin/tests/host/test_host_api.py +++ b/netbox_docker_plugin/tests/host/test_host_api.py @@ -123,6 +123,35 @@ def test_delete_object(self): objectchanges[1].action, ObjectChangeActionChoices.ACTION_UPDATE ) + def test_bulk_delete_objects(self): + """ + DELETE a set of objects in a single request. + """ + # Add object-level permission + obj_perm = ObjectPermission(name="Test permission", actions=["delete"]) + obj_perm.save() + # pylint: disable=E1101 + obj_perm.users.add(self.user) + # pylint: disable=E1101 + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Target the three most recently created objects to avoid triggering recursive deletions + # (e.g. with MPTT objects) + id_list = list( + self._get_queryset().order_by("-id").values_list("id", flat=True)[:3] + ) + self.assertEqual( + len(id_list), 3, "Insufficient number of objects to test bulk deletion" + ) + data = [{"id": id} for id in id_list] + + initial_count = self._get_queryset().count() + response = self.client.delete( + self._get_list_url(), data, format="json", **self.header + ) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(self._get_queryset().count(), initial_count - 3) + @classmethod def setUpTestData(cls) -> None: Host.objects.create(endpoint="http://localhost:8080", name="host1") diff --git a/netbox_docker_plugin/tests/host/test_host_views.py b/netbox_docker_plugin/tests/host/test_host_views.py index e17ff74..697584b 100644 --- a/netbox_docker_plugin/tests/host/test_host_views.py +++ b/netbox_docker_plugin/tests/host/test_host_views.py @@ -65,6 +65,27 @@ def test_delete_object_with_permission(self): objectchanges[1].action, ObjectChangeActionChoices.ACTION_UPDATE ) + def test_bulk_delete_objects_with_permission(self): + pk_list = list(self._get_queryset().values_list("pk", flat=True))[:3] + data = { + "pk": pk_list, + "confirm": True, + "_confirm": True, # Form button + } + + # Assign unconstrained permission + obj_perm = ObjectPermission(name="Test permission", actions=["delete"]) + obj_perm.save() + # pylint: disable=E1101 + obj_perm.users.add(self.user) + # pylint: disable=E1101 + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Try POST with model-level permission + response = self.client.post(self._get_url("bulk_delete"), data) + self.assertHttpStatus(response, 302) + self.assertFalse(self._get_queryset().filter(pk__in=pk_list).exists()) + @classmethod def setUpTestData(cls): host1 = Host.objects.create(endpoint="http://localhost:8080", name="host1") diff --git a/netbox_docker_plugin/tests/network/test_network_views.py b/netbox_docker_plugin/tests/network/test_network_views.py index 3d0c714..a711d91 100644 --- a/netbox_docker_plugin/tests/network/test_network_views.py +++ b/netbox_docker_plugin/tests/network/test_network_views.py @@ -21,8 +21,8 @@ def setUpTestData(cls): network1 = Network.objects.create(name="network1", host=host1) network2 = Network.objects.create(name="network2", host=host2, driver="bridge") - network3 = Network.objects.create(name="network3", host=host3, driver="host") - network4 = Network.objects.create(name="network4", host=host3, driver=None) + network3 = Network.objects.create(name="network3", host=host3, driver=None) + network4 = Network.objects.create(name="network4", host=host3, driver="host") cls.form_data = { "name": "image5", diff --git a/netbox_docker_plugin/urls.py b/netbox_docker_plugin/urls.py index 5fb9d1a..4eaf0cc 100644 --- a/netbox_docker_plugin/urls.py +++ b/netbox_docker_plugin/urls.py @@ -22,14 +22,19 @@ network_setting as network_setting_views, registry as registry_views, device as device_views, - log_driver_option as log_driver_option_views + log_driver_option as log_driver_option_views, + sysctl as sysctl_views, ) 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/import/", host_views.HostBulkImportView.as_view(), name="host_bulk_import"), + path( + "hosts/import/", + host_views.HostBulkImportView.as_view(), + name="host_bulk_import", + ), path("hosts/edit/", host_views.HostBulkEditView.as_view(), name="host_bulk_edit"), path( "hosts/delete/", @@ -69,7 +74,9 @@ path("images/", image_views.ImageListView.as_view(), name="image_list"), path("images/add/", image_views.ImageEditView.as_view(), name="image_add"), path( - "images/import/", image_views.ImageBulkImportView.as_view(), name="image_bulk_import" + "images/import/", + image_views.ImageBulkImportView.as_view(), + name="image_bulk_import", ), path( "images/edit/", image_views.ImageBulkEditView.as_view(), name="image_bulk_edit" @@ -454,4 +461,25 @@ device_views.DeviceDeleteView.as_view(), name="device_delete", ), + # Sysctl + path( + "sysctls/", + sysctl_views.SysctlListView.as_view(), + name="sysctl_list", + ), + path( + "sysctls/add/", + sysctl_views.SysctlEditView.as_view(), + name="sysctl_add", + ), + path( + "sysctls//edit/", + sysctl_views.SysctlEditView.as_view(), + name="sysctl_edit", + ), + path( + "sysctls//delete/", + sysctl_views.SysctlDeleteView.as_view(), + name="sysctl_delete", + ), ) diff --git a/netbox_docker_plugin/views/sysctl.py b/netbox_docker_plugin/views/sysctl.py new file mode 100644 index 0000000..9bd6086 --- /dev/null +++ b/netbox_docker_plugin/views/sysctl.py @@ -0,0 +1,27 @@ +"""Sysctl views definitions""" + +from netbox.views import generic +from .. import tables, filtersets +from ..forms.sysctl import SysctlForm +from ..models.container import Sysctl + + +class SysctlListView(generic.ObjectListView): + """Sysctl list view definition""" + + queryset = Sysctl.objects.all() + table = tables.SysctlTable + filterset = filtersets.SysctlFilterSet + + +class SysctlEditView(generic.ObjectEditView): + """Sysctl edition view definition""" + + queryset = Sysctl.objects.all() + form = SysctlForm + + +class SysctlDeleteView(generic.ObjectDeleteView): + """Sysctl delete view definition""" + + queryset = Sysctl.objects.all() diff --git a/pyproject.toml b/pyproject.toml index 78d7c5f..051ab93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-docker-plugin" -version = "4.8.0" +version = "4.9.0" authors = [ { name="Vincent Simonin", email="vincent@saashup.com" }, { name="David Delassus", email="david.jose.delassus@gmail.com" }