diff --git a/nbxsync/api/views/zabbixsync.py b/nbxsync/api/views/zabbixsync.py index 4a0b4f5..2a326aa 100644 --- a/nbxsync/api/views/zabbixsync.py +++ b/nbxsync/api/views/zabbixsync.py @@ -5,16 +5,15 @@ from rest_framework.viewsets import ViewSet from django.apps import apps from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema -from nbxsync.api.serializers import ZabbixHostInterfaceSerializer -from nbxsync.models import ZabbixHostInterface from nbxsync.constants import OBJECT_TYPE_MODEL_MAP class ZabbixSyncViewSet(ViewSet): permission_classes = [IsAuthenticated] - serializer_class = ZabbixHostInterfaceSerializer + @extend_schema(exclude=True) def create(self, request, **kwargs): obj_type = (request.data.get('obj_type') or '').strip().lower() obj_id = request.data.get('obj_id') diff --git a/nbxsync/forms/zabbixtagassignment.py b/nbxsync/forms/zabbixtagassignment.py index 43191a1..d4dc78b 100644 --- a/nbxsync/forms/zabbixtagassignment.py +++ b/nbxsync/forms/zabbixtagassignment.py @@ -11,6 +11,7 @@ from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD from nbxsync.models import ZabbixTag, ZabbixTagAssignment, ZabbixConfigurationGroup +from nbxsync.utils import get_assigned_zabbixobjects __all__ = ('ZabbixTagAssignmentForm', 'ZabbixTagAssignmentFilterForm', 'ZabbixTagAssignmentBulkEditForm') logger = logging.getLogger(__name__) @@ -71,21 +72,23 @@ def assignable_fields(self): def __init__(self, *args, **kwargs): instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() + target = None if instance and instance.assigned_object: + target = instance.assigned_object for model_class, field in ASSIGNMENT_TYPE_TO_FIELD.items(): if isinstance(instance.assigned_object, model_class): - initial[field] = instance.assigned_object + initial[field] = target break elif 'assigned_object_type' in initial and 'assigned_object_id' in initial: try: content_type = ContentType.objects.get(pk=initial['assigned_object_type']) - obj = content_type.get_object_for_this_type(pk=initial['assigned_object_id']) + target = content_type.get_object_for_this_type(pk=initial['assigned_object_id']) for model_class, field in ASSIGNMENT_TYPE_TO_FIELD.items(): - if isinstance(obj, model_class): - initial[field] = obj.pk + if isinstance(target, model_class): + initial[field] = target.pk break except Exception as e: @@ -95,6 +98,19 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = initial super().__init__(*args, **kwargs) + if target is not None: + assigned = get_assigned_zabbixobjects(target) + excluded_ids = set() + for assigned_tag in assigned['tags']: + excluded_ids.add(assigned_tag.zabbixtag_id) + + if instance is not None and instance.pk and instance.zabbixtag_id: + excluded_ids.discard(instance.zabbixtag_id) + + if excluded_ids: + self.fields['zabbixtag'].queryset = ZabbixTag.objects.exclude(pk__in=excluded_ids) + self.fields['zabbixtag'].widget.add_query_params({'id__n': list(excluded_ids)}) + def clean(self): super().clean() diff --git a/nbxsync/forms/zabbixtemplateassignment.py b/nbxsync/forms/zabbixtemplateassignment.py index c302f4f..78f5cd3 100644 --- a/nbxsync/forms/zabbixtemplateassignment.py +++ b/nbxsync/forms/zabbixtemplateassignment.py @@ -11,6 +11,7 @@ from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixTemplate, ZabbixTemplateAssignment, ZabbixConfigurationGroup +from nbxsync.utils import get_assigned_zabbixobjects __all__ = ('ZabbixTemplateAssignmentForm', 'ZabbixTemplateAssignmentFilterForm') logger = logging.getLogger(__name__) @@ -71,8 +72,10 @@ def assignable_fields(self): def __init__(self, *args, **kwargs): instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() + target = None if instance and instance.assigned_object: + target = instance.assigned_object for model_class, field in ASSIGNMENT_TYPE_TO_FIELD.items(): if isinstance(instance.assigned_object, model_class): initial[field] = instance.assigned_object @@ -81,11 +84,11 @@ def __init__(self, *args, **kwargs): elif 'assigned_object_type' in initial and 'assigned_object_id' in initial: try: content_type = ContentType.objects.get(pk=initial['assigned_object_type']) - obj = content_type.get_object_for_this_type(pk=initial['assigned_object_id']) + target = content_type.get_object_for_this_type(pk=initial['assigned_object_id']) for model_class, field in ASSIGNMENT_TYPE_TO_FIELD.items(): - if isinstance(obj, model_class): - initial[field] = obj.pk + if isinstance(target, model_class): + initial[field] = target.pk break except Exception as e: @@ -95,6 +98,19 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = initial super().__init__(*args, **kwargs) + if target is not None: + assigned = get_assigned_zabbixobjects(target) + excluded_ids = set() + for assigned_template in assigned['templates']: + excluded_ids.add(assigned_template.zabbixtemplate_id) + + if instance is not None and instance.pk and instance.zabbixtemplate_id: + excluded_ids.discard(instance.zabbixtemplate_id) + + if excluded_ids: + self.fields['zabbixtemplate'].queryset = ZabbixTemplate.objects.exclude(pk__in=excluded_ids) + self.fields['zabbixtemplate'].widget.add_query_params({'id__n': list(excluded_ids)}) + def clean(self): super().clean() diff --git a/nbxsync/jobs/synctemplates.py b/nbxsync/jobs/synctemplates.py index e64fee1..71d6b93 100644 --- a/nbxsync/jobs/synctemplates.py +++ b/nbxsync/jobs/synctemplates.py @@ -91,7 +91,7 @@ def run(self): self.instance.last_sync_state = True self.instance.last_sync = make_aware(datetime.datetime.now()) - self.instance.last_sync_message = 'Succes' + self.instance.last_sync_message = 'Success' self.instance.save() except ConnectionError as e: diff --git a/nbxsync/models/zabbixmacroassignment.py b/nbxsync/models/zabbixmacroassignment.py index 5e96262..0e10e07 100644 --- a/nbxsync/models/zabbixmacroassignment.py +++ b/nbxsync/models/zabbixmacroassignment.py @@ -1,15 +1,21 @@ +import re + from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from netbox.models import NetBoxModel +from jinja2 import TemplateError, TemplateSyntaxError, UndefinedError +from utilities.jinja2 import render_jinja2 +from netbox.models import NetBoxModel from nbxsync.constants import ASSIGNMENT_MODELS -from nbxsync.models import SyncInfoModel +from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup __all__ = ('ZabbixMacroAssignment',) +TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') + class ZabbixMacroAssignment(SyncInfoModel, NetBoxModel): zabbixmacro = models.ForeignKey('nbxsync.ZabbixMacro', on_delete=models.CASCADE, related_name='zabbixmacroassignment') @@ -59,6 +65,42 @@ def __str__(self): return f'{self.zabbixmacro.macro[:-1]}:{self.context}}}' return self.zabbixmacro.macro + def is_template(self): + return bool(TEMPLATE_PATTERN.search(self.value)) + + def render(self, **context): + if isinstance(self.assigned_object, ZabbixConfigurationGroup): + return self.value, True + + context = self.get_context(**context) + + try: + output = render_jinja2(self.value, context) + output = output.replace('\r\n', '\n') + return output, True + + except TemplateSyntaxError as err: + return self.value, False + + except UndefinedError as err: + return self.value, False + + except TemplateError as err: + return self.value, False + + except Exception as err: + return self.value, False + + def get_context(self, **extra_context): + context = { + 'object': self.assigned_object, + 'macro': self.zabbixmacro.macro, + 'value': self.value, + 'description': self.zabbixmacro.description, + } + context.update(extra_context) + return context + @property def full_name(self): return self diff --git a/nbxsync/tables/columns.py b/nbxsync/tables/columns.py index 1e8738a..4910f69 100644 --- a/nbxsync/tables/columns.py +++ b/nbxsync/tables/columns.py @@ -39,3 +39,13 @@ def render(self, value): if model is None: return capfirst(value.model.replace('_', ' ')) return capfirst(model._meta.verbose_name) + + +class JinjaValueColumn(tables.Column): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def render(self, value, record, table): + instance = getattr(table, 'instance', None) + output, success = record.render(object=instance) + return output diff --git a/nbxsync/tables/zabbixmacroassignment.py b/nbxsync/tables/zabbixmacroassignment.py index 34bfcd1..c748e9d 100644 --- a/nbxsync/tables/zabbixmacroassignment.py +++ b/nbxsync/tables/zabbixmacroassignment.py @@ -6,7 +6,7 @@ from nbxsync.models import ZabbixMacroAssignment from nbxsync.tables import ZabbixInheritedAssignmentTable -from nbxsync.tables.columns import ContentTypeModelNameColumn, InheritanceAwareActionsColumn +from nbxsync.tables.columns import ContentTypeModelNameColumn, InheritanceAwareActionsColumn, JinjaValueColumn from nbxsync.choices import ZabbixMacroTypeChoices __all__ = ('ZabbixMacroAssignmentTable', 'ZabbixMacroAssignmentObjectViewTable') @@ -18,6 +18,11 @@ class ZabbixMacroAssignmentTable(ZabbixInheritedAssignmentTable, NetBoxTable): zabbixmacro = tables.Column(accessor='zabbixmacro.macro', verbose_name=_('Zabbix Macro'), linkify={'viewname': 'plugins:nbxsync:zabbixmacro', 'args': [A('zabbixmacro.pk')]}) macro_full_name = tables.Column(accessor='full_name', verbose_name=_('Macro'), order_by='zabbixmacro__macro') actions = InheritanceAwareActionsColumn() + rendered_output = JinjaValueColumn( + verbose_name='Value', + orderable=False, + accessor='value', + ) class Meta(NetBoxTable.Meta): model = ZabbixMacroAssignment @@ -30,10 +35,10 @@ class Meta(NetBoxTable.Meta): 'inherited_from', 'is_context', 'regex', - 'value', + 'rendered_output', 'created', 'last_updated', - 'actions,', + 'actions', ) default_columns = ( 'pk', @@ -43,7 +48,7 @@ class Meta(NetBoxTable.Meta): 'macro_full_name', 'is_context', 'regex', - 'value', + 'rendered_output', 'inherited_from', ) @@ -58,7 +63,11 @@ class ZabbixMacroAssignmentObjectViewTable(ZabbixInheritedAssignmentTable, NetBo assigned_object = tables.Column(verbose_name=_('Assigned To'), linkify=True, orderable=False) macro_full_name = tables.Column(accessor='full_name', verbose_name=_('Macro'), order_by='zabbixmacro__macro', linkify={'viewname': 'plugins:nbxsync:zabbixmacro', 'args': [A('zabbixmacro.pk')]}) actions = InheritanceAwareActionsColumn() - value = tables.Column(verbose_name='Value') + rendered_output = JinjaValueColumn( + verbose_name='Value', + orderable=False, + accessor='value', + ) class Meta(NetBoxTable.Meta): model = ZabbixMacroAssignment @@ -71,7 +80,7 @@ class Meta(NetBoxTable.Meta): 'inherited_from', 'is_regex', 'context', - 'value', + 'rendered_output', 'created', 'last_updated', 'actions,', @@ -81,7 +90,7 @@ class Meta(NetBoxTable.Meta): 'macro_full_name', 'is_regex', 'context', - 'value', + 'rendered_output', 'inherited_from', ) diff --git a/nbxsync/templates/nbxsync/forms/zabbixhostinterface.html b/nbxsync/templates/nbxsync/forms/zabbixhostinterface.html index 2a5d916..dbfa9d9 100644 --- a/nbxsync/templates/nbxsync/forms/zabbixhostinterface.html +++ b/nbxsync/templates/nbxsync/forms/zabbixhostinterface.html @@ -123,7 +123,7 @@

SNMPv3 Configuration

{% for fieldset in form.fieldsets %} - {% if fieldset.name == "Assignment" %} + {% if fieldset.name == _('Assignment') %} {% render_fieldset form fieldset %} {% endif %} {% endfor %} diff --git a/nbxsync/templates/nbxsync/forms/zabbixmacroassignment.html b/nbxsync/templates/nbxsync/forms/zabbixmacroassignment.html index 27f04dc..df9e831 100644 --- a/nbxsync/templates/nbxsync/forms/zabbixmacroassignment.html +++ b/nbxsync/templates/nbxsync/forms/zabbixmacroassignment.html @@ -23,7 +23,7 @@

Generic

{% for fieldset in form.fieldsets %} - {% if fieldset.name == "Assignment" %} + {% if fieldset.name == _('Assignment') %} {% render_fieldset form fieldset %} {% endif %} {% endfor %} diff --git a/nbxsync/templates/nbxsync/zabbixmacroassignment.html b/nbxsync/templates/nbxsync/zabbixmacroassignment.html index 503d648..4972668 100644 --- a/nbxsync/templates/nbxsync/zabbixmacroassignment.html +++ b/nbxsync/templates/nbxsync/zabbixmacroassignment.html @@ -6,6 +6,7 @@ {% load static %} {% load plugins %} {% load render_table from django_tables2 %} +{% load zabbix_macros %} {% block content %}
@@ -43,8 +44,13 @@
Zabbix Macro Assignment
{% plugin_left_page object %}
+ {% render_zabbix_macro_assignment object as rendered_output %} +
+
Rendered Value
+
{{ rendered_output|safe }}
+
{% plugin_right_page object %} {% include 'inc/panels/tags.html' %}
-
{% endblock %} diff --git a/nbxsync/templatetags/__init__.py b/nbxsync/templatetags/__init__.py index 2d374cf..054b0a2 100644 --- a/nbxsync/templatetags/__init__.py +++ b/nbxsync/templatetags/__init__.py @@ -1,4 +1,5 @@ from .zabbix_tags import * from .zabbix_hostgroups import * from .zabbix_hostinterfaces import * +from .zabbix_macros import * from .render_field import * diff --git a/nbxsync/templatetags/zabbix_macros.py b/nbxsync/templatetags/zabbix_macros.py new file mode 100644 index 0000000..dedd154 --- /dev/null +++ b/nbxsync/templatetags/zabbix_macros.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +@register.simple_tag +def render_zabbix_macro_assignment(assignment, **context): + output, success = assignment.render(**context) + return output diff --git a/nbxsync/tests/utils/test_hostgroupsync.py b/nbxsync/tests/utils/test_hostgroupsync.py index ae89744..f8df027 100644 --- a/nbxsync/tests/utils/test_hostgroupsync.py +++ b/nbxsync/tests/utils/test_hostgroupsync.py @@ -77,3 +77,15 @@ def test_set_id_dynamic_updates_existing_hostgroup(self): self.assertEqual(updated_hg.groupid, 999) self.assertEqual(updated_hg.name, 'ExistingGroup') self.assertEqual(updated_hg.zabbixserver, self.zabbixserver) + + def test_set_id_dynamic_creates_rendered_hostgroup_when_missing(self): + rendered_name, ok = self.assignment_dynamic.render() + self.assertTrue(ok) + + sync = HostGroupSync(api=None, netbox_obj=self.assignment_dynamic) + sync.set_id(321) + + created_hg = ZabbixHostgroup.objects.get(zabbixserver=self.zabbixserver, name=rendered_name) + self.assertEqual(created_hg.value, rendered_name) + self.assertEqual(created_hg.groupid, 321) + self.assertEqual(created_hg.description, 'Automatically generated from template') diff --git a/nbxsync/tests/utils/test_hostsync.py b/nbxsync/tests/utils/test_hostsync.py index 8863c23..5f2b193 100644 --- a/nbxsync/tests/utils/test_hostsync.py +++ b/nbxsync/tests/utils/test_hostsync.py @@ -239,6 +239,9 @@ def __init__(self): self.zabbixmacro = DummyZabbixMacro(type_=1, description='Test Macro') self.value = 'secret-value' + def render(self, object): + return 'secret-value', True + def __str__(self): return '{$TEST_MACRO}' @@ -265,6 +268,9 @@ def __init__(self): self.zabbixmacro = DummyZabbixMacro(type_=1, description='From NetBox') self.value = 'nb-value' + def render(self, object): + return 'nb-value', True + def __str__(self): return '{$FROM_NETBOX}' diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index f679883..59b50b7 100644 --- a/nbxsync/utils/inheritance.py +++ b/nbxsync/utils/inheritance.py @@ -18,16 +18,18 @@ def get_zabbixassignments_for_request(instance, request): assignments = get_assigned_zabbixobjects(instance) content_type = ContentType.objects.get_for_model(instance) - def table_or_none(data, table_cls): + def table_or_none(data, table_cls, attach_instance=False): if data: table = table_cls(data) table.configure(request) + if attach_instance: + table.instance = instance return table return None return { 'zabbix_template_table': table_or_none(assignments['templates'], ZabbixTemplateAssignmentObjectViewTable), - 'zabbix_macro_table': table_or_none(assignments['macros'], ZabbixMacroAssignmentObjectViewTable), + 'zabbix_macro_table': table_or_none(assignments['macros'], ZabbixMacroAssignmentObjectViewTable, attach_instance=True), 'zabbix_tag_table': table_or_none(assignments['tags'], ZabbixTagAssignmentObjectViewTable), 'zabbix_hostgroup_table': table_or_none(assignments['hostgroups'], ZabbixHostgroupAssignmentObjectViewTable), 'object': instance, diff --git a/nbxsync/utils/sync/hostgroupsync.py b/nbxsync/utils/sync/hostgroupsync.py index 183516d..1a54837 100644 --- a/nbxsync/utils/sync/hostgroupsync.py +++ b/nbxsync/utils/sync/hostgroupsync.py @@ -65,8 +65,11 @@ def set_id(self, value): name, _state = self.obj.render() - # Try to get the hostgroup by value (the Zabbix name) - hostgroup = ZabbixHostgroup.objects.get(zabbixserver=self.obj.zabbixhostgroup.zabbixserver, value=name) + zabbixserver = self.obj.zabbixhostgroup.zabbixserver + # Try to find an existing local representation for the rendered Zabbix group. + # Dynamic groups created from templates are stored with the rendered group + # name in both `name` and `value`, so check either field without raising. + hostgroup = ZabbixHostgroup.objects.filter(zabbixserver=zabbixserver, name=name).first() or ZabbixHostgroup.objects.filter(zabbixserver=zabbixserver, value=name).first() if hostgroup: hostgroup.groupid = value @@ -74,7 +77,7 @@ def set_id(self, value): return # Not found by name, so create it - ZabbixHostgroup(zabbixserver=self.obj.zabbixhostgroup.zabbixserver, name=name, value=name, groupid=value, description='Automatically generated from template').save() + ZabbixHostgroup(zabbixserver=zabbixserver, name=name, value=name, groupid=value, description='Automatically generated from template').save() def get_id(self): if self.obj.is_template(): diff --git a/nbxsync/utils/sync/hostinterfacesync.py b/nbxsync/utils/sync/hostinterfacesync.py index 6398e15..93a8cf8 100644 --- a/nbxsync/utils/sync/hostinterfacesync.py +++ b/nbxsync/utils/sync/hostinterfacesync.py @@ -77,6 +77,9 @@ def get_create_params(self): def get_update_params(self, **kwargs): params = self.get_create_params() params['interfaceid'] = self.obj.interfaceid + + # Zabbix forbids changing hostid on update + params.pop('hostid', None) return params def result_key(self): @@ -126,3 +129,25 @@ def sync_from_zabbix(self, data): except Exception as err: self.obj.update_sync_info(success=False, message=str(err)) + + def find_by_id(self): + if not self.obj.interfaceid: + return [] + + found = self.api_object().get(interfaceids=self.obj.interfaceid, output=['interfaceid', 'hostid']) + + if not found: + # interfaceid no longer exists in Zabbix, clear it so the next sync cycle falls through to find_by_name / try_create + self.obj.interfaceid = None + self.obj.save(update_fields=['interfaceid']) + return [] + + expected_hostid = str(self.context.get('hostid') or '') + if expected_hostid and str(found[0]['hostid']) != expected_hostid: + # interfaceid exists but belongs to a different host: this is a stale reference. + # Clear it and let the sync re-establish the correct interface + self.obj.interfaceid = None + self.obj.save(update_fields=['interfaceid']) + return [] + + return found diff --git a/nbxsync/utils/sync/hostsync.py b/nbxsync/utils/sync/hostsync.py index 1882144..e92c462 100644 --- a/nbxsync/utils/sync/hostsync.py +++ b/nbxsync/utils/sync/hostsync.py @@ -96,12 +96,13 @@ def get_proxy_or_proxygroup(self): def get_defined_macros(self): result = [] for macro in self.context.get('all_objects', {}).get('macros'): + rendered_value, _ = macro.render(object=self.obj.assigned_object) result.append( { 'macro': str(macro), 'type': macro.zabbixmacro.type, 'description': macro.zabbixmacro.description, - 'value': macro.value, + 'value': rendered_value, } ) @@ -406,12 +407,20 @@ def delete(self): pass return + # The assigned object (Device/VM) may already be gone if this job runs + # after a cascade delete. Resolve it once and guard all uses below. + assigned_object = self.obj.assigned_object + if assigned_object is None: + # NetBox object is gone — still delete the Zabbix host, then clean up + # what we can without touching the now-invalid assignment row. + try: + self.api_object().delete([self.obj.hostid]) + except Exception as e: + raise RuntimeError(f'Failed to delete orphaned host {self.obj.hostid} from Zabbix: {e}') + return + try: - # Check for maintenances where this host is attached to - # If found: - # Check if this host is the only host for this maintenance; if so - delete the maintenance window - # If not: delete the host from the maintenance window - object_ct = ContentType.objects.get_for_model(self.obj.assigned_object) + object_ct = ContentType.objects.get_for_model(assigned_object) maintenances = self.api.maintenance.get(hostids=[self.obj.hostid], selectHosts='extend') for mw in maintenances: # Check per maintenance window if this host is the only host in the window or not. If it is, we can delete it @@ -421,7 +430,7 @@ def delete(self): hosts = [{'hostid': host['hostid']} for host in mw['hosts'] if int(host['hostid']) != self.obj.hostid] # Update the maintenance window in Zabbix without our hostid in it self.api.maintenance.update(maintenanceid=mw['maintenanceid'], hosts=hosts) - for assignment in ZabbixMaintenanceObjectAssignment.objects.filter(maintenanceid=mw['maintenanceid'], assigned_object_type=object_ct, assigned_object_id=self.obj.assigned_object.id): + for assignment in ZabbixMaintenanceObjectAssignment.objects.filter(maintenanceid=mw['maintenanceid'], assigned_object_type=object_ct, assigned_object_id=assigned_object.id): assignment.delete() # Delete the Assignment from Netbox; # If our host is the only one in the Maintenance Object @@ -442,7 +451,7 @@ def delete(self): # Also clear host IDs from related interfaces try: - ZabbixHostInterface.objects.filter(assigned_object_type=self.obj.assigned_object_type, assigned_object_id=self.obj.assigned_object.id, zabbixserver=self.obj.zabbixserver).update(interfaceid=None) + ZabbixHostInterface.objects.filter(assigned_object_type=self.obj.assigned_object_type, assigned_object_id=assigned_object.id, zabbixserver=self.obj.zabbixserver).update(interfaceid=None) self.obj.update_sync_info(success=True, message='Host deleted from Zabbix.') except Exception: