diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..43ae0e2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/docs/changelog.md b/docs/changelog.md index dfac4107..3eaf0b44 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,22 @@ # Changelog +## [Unreleased] + +### New features + +- Added `exclude_tag` configuration setting to exclude hosts from Zabbix sync entirely via a ZabbixTag assigned to any object in the inheritance chain (`ZabbixTag.tag` name match) +- Added Site/SiteGroup/Region inheritance paths (appended after role/platform so upgrades do not change Role/Platform precedence); cluster site uses `cluster._site` for VM site scope +- Added NetBox Tag as an assignment target: any assignment can be pointed at a Tag; tagged Devices/VMs/VDCs inherit it at object level with automatic add/remove lifecycle (`Tag: ` shown as source) + +### Improvements + +- Plugin requires NetBox ≥4.2.6 (`PluginConfig.min_version`) +- Inherited sync status on the Zabbix tab uses a neutral indicator (distinct from a direct local assignment) + +### Bug fixes + +- VirtualMachines no longer inherit assignments via `device`-prefixed `inheritance_chain` paths. On NetBox 4.3+, `VirtualMachine.device` links a guest to its hosting device; skipping those paths prevents host manufacturer/role/device-type templates from leaking onto guests. Virtual Device Contexts still walk those paths + ## [1.0.0] - Initial Release - Loads of features, :) diff --git a/docs/configuration.md b/docs/configuration.md index 90c036fe..f9e3ed64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,12 @@ The plugin is configuration to do exactly what you want, by means of the plugin ['cluster'], ['cluster', 'type'], ['type'], + # Hierarchy appended after device/role/platform (first-seen wins) + ['device', 'site'], + ['site'], + ['site', 'group'], + ['site', 'region'], + ['cluster', '_site'], ], 'backgroundsync': { 'objects': { @@ -105,10 +111,46 @@ The plugin is configuration to do exactly what you want, by means of the plugin 'objtag_type': 'nb_type', 'objtag_id': 'nb_id', 'custom_field_hostname':'', - 'custom_field_display_name':'' + 'custom_field_display_name':'', + 'exclude_tag': '', } ``` +## Inheritance Chain + +The `inheritance_chain` setting defines which NetBox objects are traversed when resolving Zabbix assignments. Assignments (templates, tags, hostgroups, macros, proxy/server, interfaces, inventory, configuration groups) made on any object in the chain are inherited by the device or VM being synced, with direct assignments taking priority. Within inherited sources, **first path wins** (leaf-first order as listed). + +Host interfaces are the exception: they are defined on a Device/VM directly, on a `ZabbixConfigurationGroup`, or on a NetBox Tag (as a reusable interface template), because an interface needs a per-device endpoint. To apply interfaces to a whole Site, SiteGroup or Region, assign a Configuration Group at that level — its interfaces are then cloned onto every inheriting device with that device's primary IP. + +### VirtualMachine and `device`-prefixed paths + +Paths that start with `device` (for example `['device']`, `['device', 'role']`, `['device', 'device_type', 'manufacturer']`) describe the associated physical device. + +Virtual Device Contexts keep these paths (a VDC is part of its parent device). VirtualMachines skip them: on NetBox 4.3+, `VirtualMachine.device` links a guest to its hosting device, and walking that path would leak host hardware assignments onto the guest. VMs still inherit via cluster, site, role, platform and tag paths that apply to the VM itself. + +### Site, SiteGroup, and Region Inheritance + +Hierarchy paths are appended after device/role/platform/manufacturer/cluster paths so upgrading into Site inheritance does not silently override existing Role or Platform assignments. SiteGroup and Region ancestors are walked automatically when a group or region is reached. + +| Path | Description | +|------|-------------| +| `['device', 'site']` | The device's site (also VDC → device → site; not walked for VirtualMachines) | +| `['site']` | Site (direct) | +| `['site', 'group']` | The site's SiteGroup (parents walked) | +| `['site', 'region']` | The site's region (parents walked) | +| `['cluster', '_site']` | The cluster's scoped site for VMs (`CachedScopeMixin._site`) | + +If you previously customized `inheritance_chain` with Site paths ahead of Role/Platform, review hosts that have both a Site-level and a Role/Platform-level assignment — effective winners may change. Prefer appending hierarchy paths. + +For example, assigning a `ZabbixServerAssignment` (proxy) to a `SiteGroup` means every device at every site in that SiteGroup inherits the proxy — no per-device assignment needed. + +### Tag-based assignments + +NetBox Tags are assignment targets: hostgroup, template, tag, macro, server, inventory, configuration group and host interface assignments can be pointed at a Tag. Every Device, VDC or VirtualMachine carrying that tag then inherits the assignment. Direct assignments on the object still take priority. Tag-targeted rows are collected before the `inheritance_chain` paths (first seen wins). + +Tagging an object adds the membership on the next sync; removing the tag removes it. The inherited source is shown as `Tag: `. Create Tag-targeted assignments via the API (`assigned_object_type` / `assigned_object_id`); the assignment forms expose Site hierarchy pickers but not a Tag picker. + + ## Configuration values ### Source of Truth @@ -270,6 +312,16 @@ These tags allow you to navigate from a Zabbix host back to the corresponding N ### custom_field_hostname and custom_field_display_name You can use these fields to map the connection between NetBox and the Zabbix hostname and display name. The device name is used as the default. +### exclude_tag + +When set to a non-empty string (e.g. `'do_not_monitor'`), any `ZabbixTagAssignment` with a tag matching this name — whether assigned directly on a Device/VM or inherited from a Role, Platform, Site, SiteGroup, Region, Manufacturer, or Configuration Group — causes the host to be excluded from Zabbix sync entirely. No Zabbix host is created, and if a host was previously synced it is deleted from Zabbix. + +This is useful for excluding device classes that should never be monitored (e.g. desktop PCs, VDI sessions, test lab devices) without removing their Site or Platform assignments. + +The tag itself is never pushed to Zabbix — it is only used as a signal during sync resolution and is filtered out before Jinja2 rendering. + +Defaults to `''` (empty string = feature disabled). + ## Enabling and Disabling Synchronization Two separate `sync_enabled` flags control whether synchronization to Zabbix is active. diff --git a/docs/dynamic_values.md b/docs/dynamic_values.md index e9b0d012..0b7d5e02 100644 --- a/docs/dynamic_values.md +++ b/docs/dynamic_values.md @@ -12,15 +12,9 @@ For example, if a Hostgroup is given the value: {{ object.site.name }} ``` -and is applied to a `Device`, it will render as the device's `Site Name`. Here, object refers to the entity to which the assignment is made. This distinction is important: if a `Hostgroup` is assigned to a `DeviceType`, then the `DeviceType` becomes the object—even if the `Hostgroup` is later inherited by a `Device`. +and is applied to a `Device`, it will render as the device's Site name. -Therefore, using the following template: - -```jinja2 -{{ object.site.name }} -``` - -on a `DeviceType` does not make sense, because a `DeviceType` does not have a `Site` attribute. +During host synchronisation, tag, macro and host-inventory templates are rendered against the Device, VDC or VirtualMachine being synced — even when the assignment is inherited. Hostgroup templates still render with `object` as the assignment target, so hostgroup Jinja that needs host fields should be assigned where those fields exist. ## Context @@ -38,32 +32,31 @@ Tags are rendered within a context that includes the following information: | Key | Value | Explanation | |-------------|-----------------------|----------------------------------------------------------------------------------------------| -| object | assigned_object | Refers to the assigned object; this could be a DeviceType, Device, VirtualMachine, etc. | -| tag | zabbixtag.tag | Contains the Zabbix Tag value that this assignment refers to | -| value | zabbixtag.value | The value of the Zabbix Tag (typically the Jinja2 template) | -| name | zabbixtag.name | The name of the Zabbix Tag | -| description | zabbixtag.description | The description of the Zabbix Tag | +| object | assigned_object | The assignment target. During host sync this is the Device/VM/VDC being synced | +| tag | zabbixtag.tag | Contains the Zabbix Tag value that this assignment refers to | +| value | zabbixtag.value | The value of the Zabbix Tag (typically the Jinja2 template) | +| name | zabbixtag.name | The name of the Zabbix Tag | +| description | zabbixtag.description | The description of the Zabbix Tag | ### Hostgroup Just like tags, hostgroups are rendered in a context: -| Key | Value | Explanation | -|-------------|-----------------------|----------------------------------------------------------------------------------------------| -| object | assigned_object | Refers to the assigned object; this could be a DeviceType, Device, VirtualMachine, etc. | -| value | zabbixhostgroup.value | The value of the Zabbix Hostgroup (typically the Jinja2 template) | -| name | zabbixhostgroup.name | The name of the Zabbix Hostgroup | +| Key | Value | Explanation | +|-------------|-----------------------|-------------| +| object | assigned_object | The assignment target (not overridden to the synced host during host sync) | +| value | zabbixhostgroup.value | The value of the Zabbix Hostgroup (typically the Jinja2 template) | +| name | zabbixhostgroup.name | The name of the Zabbix Hostgroup | ### Host Inventory Each field on a `ZabbixHostInventory` record is rendered individually. The context is simpler than for tags and hostgroups: -| Key | Value | Explanation | -|----------|------------------|--------------------------------------------------------------| -| `object` | assigned_object | The Device, VDC, or VirtualMachine this inventory belongs to | +| Key | Value | Explanation | +|----------|------------------|-------------| +| `object` | assigned_object | The assignment target. During host sync this is the Device/VM/VDC being synced | -Because `ZabbixHostInventory` is assigned directly to a Device, VDC, or VM (never to a DeviceType or other inheritance-chain object), `object` always -refers to the host itself. This means device attributes like `object.site.name`, `object.rack.name`, or `object.primary_ip` are always available and always resolve to the correct host. +Inventory can be assigned on hierarchy objects and inherited; during host sync `object` is the host, so fields such as `object.site.name` or `object.primary_ip` resolve correctly. Note that each field has a maximum character length enforced at render time, values that exceed the limit are silently truncated. The `inventory_mode` field controls how Zabbix treats the inventory: - `Manual` (the default) means Zabbix only updates inventory via the API, which is how nbxSync writes it. diff --git a/docs/installation.md b/docs/installation.md index 629de65e..db47c3a4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -9,7 +9,7 @@ Also, replace `netbox_secrets` with `nbxsync` obviously. ## Normal install ### Prerequisites -- NetBox >= 4.x +- NetBox >= 4.2.6 - Python >= 3.8 - Zabbix server >= 7.0 @@ -85,6 +85,11 @@ PLUGINS_CONFIG = { ['cluster'], ['cluster', 'type'], ['type'], + ['device', 'site'], + ['site'], + ['site', 'group'], + ['site', 'region'], + ['cluster', '_site'], ], 'backgroundsync': { 'objects': { @@ -112,6 +117,7 @@ PLUGINS_CONFIG = { 'objtag_id': 'nb_id', 'custom_field_hostname': '', 'custom_field_display_name': '', + 'exclude_tag': '', } } ``` diff --git a/docs/models.md b/docs/models.md index 9a7b0750..6be68034 100644 --- a/docs/models.md +++ b/docs/models.md @@ -44,7 +44,7 @@ Assigns a template to a NetBox object (device, VM, etc.). | Field | Type | Description | |------------------------|--------------|---------------------------------------| | `zabbixtemplate` | ForeignKey | Linked Zabbix template | -| `assigned_object` | Generic FK | Device, VM, Interface, etc. | +| `assigned_object` | Generic FK | Device, VM, Site, Tag, etc. (see Assignment Scope) | Templates can be inherited based on device/site hierarchy. @@ -62,7 +62,7 @@ Defines a user macro at the Zabbix Server or Zabbix Template level. These macros ### `ZabbixMacroAssignment` -Assigns a user macro to a specific NetBox object within the inheritance chain (Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, or ClusterType). +Assigns a user macro to a specific NetBox object within the inheritance chain (Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, or Tag). Macros assigned here override template-level macros of the same name on the resulting Zabbix host. | Field | Description | @@ -86,7 +86,7 @@ Includes rich SNMP and TLS configuration fields. | `port` | Connection port | | `tls_*` | TLS credentials if applicable | | `snmp_*` | SNMPv3 credentials | -| `assigned_object`| Mapped to NetBox interface or device | +| `assigned_object`| Device, VDC, VirtualMachine, ZabbixConfigurationGroup, or Tag | ## Sync & Assignment Models @@ -169,13 +169,13 @@ For Zabbix Tags, only statically defined objects are supported - as there is no ### `ZabbixConfigurationGroup` -Models a group of configuration settings (such as `ZabbixServer`, `ZabbixHostInterface` et cetera) that are *replicated* to all assigned Devices, Virtual Device Contexts or VirtualMachines. +Models a group of configuration settings (such as `ZabbixServer`, `ZabbixHostInterface` et cetera) that are *replicated* to all assigned objects. Please note that on the `ZabbixHostInterface`, no IP address needs to be entered: upon replicating this to the assigned object, the *primary IP Address* will be used on the `ZabbixHostInterface` ### `ZabbixConfigurationGroupAssignment` -Links a Device/Virtual Device Context/VirtualMachine to a `ZabbixConfigurationGroup` and as such determines the applied configuration on the linked object. Device/Virtual Device Context/VirtualMachines can only be assigned to a single `ZabbixConfigurationGroup` +Links a NetBox object to a `ZabbixConfigurationGroup`. Besides Devices, Virtual Device Contexts and VirtualMachines, the group can be assigned on Site, SiteGroup, Region, Manufacturer, Role, DeviceType, Platform, Cluster, ClusterType or Tag so members inherit the group through the inheritance chain. The same object can only be assigned once to the same Configuration Group. --- @@ -184,27 +184,39 @@ Links a Device/Virtual Device Context/VirtualMachine to a `ZabbixConfigurationGr Templates, macros, and hostgroups can be inherited across these chains, by default: ```plaintext -Manufacturer → Device Type → Platform → Role → Device -Manufacturer → Device Type → Platform → Role → Virtual Device Context -Cluster → VirtualMachine +Device / Virtual Device Context + ├─ Role (and Role parent) + ├─ DeviceType → Manufacturer + ├─ Platform → Manufacturer + ├─ Site → SiteGroup / Region + └─ NetBox Tags on the object + +VirtualMachine + ├─ Role + ├─ Platform → Manufacturer + ├─ Cluster → ClusterType + ├─ Site / cluster._site → SiteGroup / Region + └─ NetBox Tags on the object ``` -However, this is [configurable](configuration.md). +Paths that start with `device` apply to Devices and Virtual Device Contexts. For VirtualMachines they are skipped: on NetBox 4.3+, `VirtualMachine.device` points at the hosting device, and walking that path would leak host hardware assignments onto guests. + +Tag-targeted assignments are collected from the object's NetBox tags before the `inheritance_chain` paths (first seen wins). See [Inheritance Chain](configuration.md#inheritance-chain). ## Assignment Scope Not all assignment types can be attached to the same set of NetBox objects. The table below shows what each model accepts as `assigned_object`. -| Model | Can be assigned to | -|--------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| `ZabbixMacro` | ZabbixServer, ZabbixTemplate | -| `ZabbixServerAssignment` | Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType | -| `ZabbixTemplateAssignment` | Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType | -| `ZabbixMacroAssignment` | Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType | -| `ZabbixTagAssignment` | Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, ZabbixConfigurationGroup | -| `ZabbixHostgroupAssignment` | Device, VDC, VM, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, ZabbixConfigurationGroup | -| `ZabbixHostInterface` | Device, VDC, VM, ZabbixConfigurationGroup | -| `ZabbixHostInventory` | Device, VDC, VM (one record per object) | -| `ZabbixMaintenanceObjectAssignment` | Device, VDC, VM, ZabbixHostgroup | -| `ZabbixConfigurationGroupAssignment` | Device, VDC, VM (one group per object) | \ No newline at end of file +| Model | Can be assigned to | +|--------------------------------------|--------------------| +| `ZabbixMacro` | ZabbixServer, ZabbixTemplate | +| `ZabbixServerAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag | +| `ZabbixTemplateAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag | +| `ZabbixMacroAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag | +| `ZabbixTagAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag, ZabbixConfigurationGroup | +| `ZabbixHostgroupAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag, ZabbixConfigurationGroup | +| `ZabbixHostInterface` | Device, VDC, VM, ZabbixConfigurationGroup, Tag | +| `ZabbixHostInventory` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag, ZabbixConfigurationGroup | +| `ZabbixMaintenanceObjectAssignment` | Device, VDC, VM, ZabbixHostgroup | +| `ZabbixConfigurationGroupAssignment` | Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, DeviceRole, DeviceType, Platform, Cluster, ClusterType, Tag | \ No newline at end of file diff --git a/docs/permissions.md b/docs/permissions.md index b3618128..fe7f61b3 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -22,9 +22,9 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc ## Zabbix Hostgroup Assignment -**What it is:** The through-model that binds a NetBox object (Device/VDC/VM) to one or more Zabbix host groups. +**What it is:** The through-model that binds a NetBox object to one or more Zabbix host groups. -**How it’s used:** Decide host placement in Zabbix UI and ACLs; supports both manual and policy-driven assignments. +**How it’s used:** Decide host placement in Zabbix UI and ACLs. Assignments can be placed on Devices/VDCs/VMs directly, or on Site/SiteGroup/Region/Tag (and other inheritance targets) so members inherit them. **Typical permissions:** @@ -94,9 +94,9 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc ## Zabbix Macro Assignment -**What it is:** Attaches a macro to a specific Device/VDC/VM with precedence over template-level macros. +**What it is:** Attaches a macro to a NetBox object in the inheritance set with precedence over template-level macros. -**How it’s used:** Host-specific overrides—e.g., a unique SNMP community or threshold. +**How it’s used:** Host-specific or policy-level overrides — e.g. a unique SNMP community on a Device, or a shared value on a Site/Role/Tag. **Typical permissions:** @@ -274,9 +274,9 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc ## Zabbix Tag Assignment -**What it is:** Applies tags to a Device/VDC/VM (or template) so they appear on the Zabbix host/events. +**What it is:** Applies Zabbix tags to a NetBox object in the inheritance set so they appear on the Zabbix host/events. -**How it’s used:** Drive alert routing, dashboards, and maintenance selection. +**How it’s used:** Drive alert routing, dashboards, and maintenance selection. Can be assigned on Devices/VDCs/VMs or inherited from Site/SiteGroup/Region/Tag and similar targets. **Typical permissions:** @@ -310,9 +310,9 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc ## Zabbix Template Assignment -**What it is:** Binds a template to a Device/VDC/VM. +**What it is:** Binds a template to a NetBox object in the inheritance set. -**How it’s used:** Attach monitoring logic to assets; supports layering and overrides. +**How it’s used:** Attach monitoring logic to assets; supports direct assignment and inheritance from Site/SiteGroup/Region/Tag and similar targets. **Typical permissions:** @@ -346,9 +346,9 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc ## Zabbix Configuration Group Assignment -**What it is:** The through-model that binds a NetBox object (Device/VDC/VM) to a single Zabbix Configuration Group +**What it is:** The through-model that binds a NetBox object to a Zabbix Configuration Group -**How it’s used:** Used to assign a single Zabbix Configuration Group to a Device/VDC/VM to replicate the templated configuration +**How it’s used:** Assign a Configuration Group to a Device/VDC/VM directly, or to a Site/SiteGroup/Region/Tag (and other inheritance targets) so members inherit the group's configuration **Typical permissions:** diff --git a/docs/setup.md b/docs/setup.md index e5f9e160..60372106 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -73,7 +73,7 @@ At minimum, specify the Type, Port, and either IP Address or DNS name. A Hostgroup assignment is required !!! note "Hint" - Hostgroups can be assigned directly to the Device, VDC or VM, or inherited from the DeviceType, Cluster, Manufacturer, Platform, etc. Alternatively, Configuration Groups can be used. + Hostgroups can be assigned directly to the Device, VDC or VM, or inherited from the DeviceType, Cluster, Manufacturer, Platform, Site, SiteGroup, Region, Tag, etc. Alternatively, Configuration Groups can be used. Each host in Zabbix requires at least one Hostgroup. Create a hostgroup using the left-hand menu: `Zabbix` -> `Zabbix Hostgroups` and click `Add`. Ensure the hostgroup is associated with the same Zabbix Server. The value can be static or [dynamically rendered using a Jinja2 template](dynamic_values.md). @@ -82,8 +82,9 @@ Once the Hostgroup is created, create a Hostgroup Assignment on the Device or Vi ### Step 3d: Assign a Template !!! note "Hint" - Templates can be assigned directly to the Device or VM, or inherited from the DeviceType, Cluster, Manufacturer, Platform, etc. Alternatively, Configuration Groups can be used. + Templates can be assigned directly to the Device or VM, or inherited from the DeviceType, Cluster, Manufacturer, Platform, Site, SiteGroup, Region, Tag, etc. Alternatively, Configuration Groups can be used. +Hosts can be excluded from sync entirely by assigning a Zabbix Tag whose name matches the `exclude_tag` plugin setting (see [Configuration](configuration.md#exclude_tag)). ## Debugging diff --git a/docs/zabbixconfigurationgroup.md b/docs/zabbixconfigurationgroup.md index a0e9cfec..f2c94ede 100644 --- a/docs/zabbixconfigurationgroup.md +++ b/docs/zabbixconfigurationgroup.md @@ -2,9 +2,9 @@ ## Overview -A `ZabbixConfigurationGroup` is a **reusable configuration template** that lets you define a set of Zabbix assignments **once** and have them automatically propagated to many NetBox objects (Devices, Virtual Machines, VirtualDeviceContexts). +A `ZabbixConfigurationGroup` is a **reusable configuration template** that lets you define a set of Zabbix assignments **once** and have them automatically propagated to many NetBox objects. -Think of it as a "profile": you attach Zabbix servers, templates, tags, host groups, macros and host interfaces to the group itself, then assign that group to individual devices/VMs. Whenever a device is added to the group, it immediately inherits all of those Zabbix configurations automatically. +Think of it as a "profile": you attach Zabbix servers, templates, tags, host groups, macros and host interfaces to the group itself, then assign that group to Devices, VDCs, VirtualMachines, or hierarchy objects such as Site, SiteGroup, Region or Tag. Whenever a member is added to the group, it immediately inherits all of those Zabbix configurations automatically. --- @@ -27,12 +27,12 @@ A minimal model, just a name and a description. All the complexity lives in assi ```python class ZabbixConfigurationGroupAssignment(NetBoxModel): zabbixconfigurationgroup = ForeignKey('nbxsync.ZabbixConfigurationGroup', ...) - assigned_object_type = ForeignKey(ContentType, limit_choices_to=DEVICE_OR_VM_ASSIGNMENT_MODELS, ...) + assigned_object_type = ForeignKey(ContentType, limit_choices_to=ASSIGNMENT_MODELS, ...) assigned_object_id = PositiveBigIntegerField(...) assigned_object = GenericForeignKey(...) ``` -This is the **membership record**: it links a group to a specific Device, VirtualMachine or VirtualDeviceContext (enforced by `DEVICE_OR_VM_ASSIGNMENT_MODELS`). A unique constraint prevents the same object from being added to the same group twice. +This is the **membership record**: it links a group to a NetBox object in the inheritance set (Device, VDC, VM, Site, SiteGroup, Region, Manufacturer, Role, DeviceType, Platform, Cluster, ClusterType, Tag). A unique constraint prevents the same object from being added to the same group twice. The important distinction: this is **not** about Zabbix config per se — it is purely about group membership. The actual Zabbix config is stored on the group itself via the normal assignment models (see below). diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index b2e6816c..288dc7b6 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -92,11 +92,17 @@ class nbxSync(PluginConfig): ['cluster'], ['cluster', 'type'], ['type'], + # Hierarchy (appended so Site does not override Role/Platform on upgrade) + ['device', 'site'], + ['site'], + ['site', 'group'], + ['site', 'region'], + ['cluster', '_site'], # NetBox ≥4.2 Cluster CachedScopeMixin ], 'backgroundsync': { 'objects': { 'enabled': True, - 'interval': 60, # 1 hour + 'interval': 360, # 6 hours }, 'templates': { 'enabled': True, @@ -139,9 +145,16 @@ class nbxSync(PluginConfig): 'no_alerting_tag': 'NO_ALERTING', 'no_alerting_tag_value': '1', 'maintenance_window_duration': 3600, - 'attach_objtag': False, + # Must stay True so nb_type/nb_id tags are pushed — bindings, adoption, + # and host identity recovery depend on them. + 'attach_objtag': True, 'objtag_type': 'nb_type', 'objtag_id': 'nb_id', + 'exclude_tag': '', + # Opt-in: inheritance-driven Zabbix host deletion destroys history. + 'allow_inherited_deletion': False, + # Opt-in: adopting an unbound host lets NetBox overwrite its config. + 'adopt_existing_hosts': False, 'custom_field_hostname': '', 'custom_field_display_name': '', } diff --git a/nbxsync/api/serializers/zabbixconfigurationgroupassignment.py b/nbxsync/api/serializers/zabbixconfigurationgroupassignment.py index d93626d7..f5be88be 100644 --- a/nbxsync/api/serializers/zabbixconfigurationgroupassignment.py +++ b/nbxsync/api/serializers/zabbixconfigurationgroupassignment.py @@ -31,6 +31,11 @@ class Meta: @extend_schema_field(OpenApiTypes.OBJECT) def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object_type.model_class()) + if instance.assigned_object_type is None or instance.assigned_object is None: + return None + model = instance.assigned_object_type.model_class() + if model is None: + return None + serializer = get_serializer_for_model(model) context = {'request': self.context['request']} return serializer(instance.assigned_object, context=context).data diff --git a/nbxsync/constants/__init__.py b/nbxsync/constants/__init__.py index f1f0dd0a..329eefed 100644 --- a/nbxsync/constants/__init__.py +++ b/nbxsync/constants/__init__.py @@ -1,8 +1,10 @@ -# from .assignment_models import * -# from .assignment_type_to_field import * - -from .template_pattern import * # noqa: F401,F403 +from .add_hostinterface_button import * # noqa: F401,F403 +from .assignment_models import * # noqa: F401,F403 from .inheritance_source_colors import * # noqa: F401,F403 -from .path_labels import * # noqa: F401,F403 from .itemtype import * # noqa: F401,F403 -from .add_hostinterface_button import * # noqa: F401,F403 +from .path_labels import * # noqa: F401,F403 +from .template_pattern import * # noqa: F401,F403 + +# assignment_type_to_field imports nbxsync.models. Loading it from this package +# init would circular-import when models import TEMPLATE_PATTERN / ASSIGNMENT_MODELS. +# Import OBJECT_TYPE_MODEL_MAP from nbxsync.constants.assignment_type_to_field. diff --git a/nbxsync/constants/assignment_models.py b/nbxsync/constants/assignment_models.py index 336c1dad..968fa76c 100644 --- a/nbxsync/constants/assignment_models.py +++ b/nbxsync/constants/assignment_models.py @@ -3,10 +3,14 @@ ASSIGNMENT_MODELS = Q( Q(app_label='dcim', model='device') | Q(app_label='dcim', model='virtualdevicecontext') + | Q(app_label='dcim', model='site') + | Q(app_label='dcim', model='sitegroup') + | Q(app_label='dcim', model='region') | Q(app_label='dcim', model='manufacturer') | Q(app_label='dcim', model='devicerole') | Q(app_label='dcim', model='devicetype') | Q(app_label='dcim', model='platform') + | Q(app_label='extras', model='tag') | Q(app_label='virtualization', model='virtualmachine') | Q(app_label='virtualization', model='cluster') | Q(app_label='virtualization', model='clustertype') @@ -16,3 +20,5 @@ MAINTENANCE_ASSIGNMENT_OBJECTS = Q(Q(Q(app_label='nbxsync', model='zabbixhostgroup')) | DEVICE_OR_VM_ASSIGNMENT_MODELS) CONFIGGROUP_OBJECTS = Q(app_label='nbxsync', model='zabbixconfigurationgroup') + +TAG_OBJECTS = Q(app_label='extras', model='tag') diff --git a/nbxsync/constants/assignment_type_to_field.py b/nbxsync/constants/assignment_type_to_field.py index 90b9f58a..1d738120 100644 --- a/nbxsync/constants/assignment_type_to_field.py +++ b/nbxsync/constants/assignment_type_to_field.py @@ -1,4 +1,5 @@ -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region +from extras.models import Tag from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.models import ZabbixHostgroup, ZabbixServer, ZabbixTag, ZabbixTemplate, ZabbixConfigurationGroup @@ -14,6 +15,10 @@ Cluster: 'cluster', ClusterType: 'clustertype', VirtualDeviceContext: 'virtualdevicecontext', + Site: 'site', + SiteGroup: 'sitegroup', + Region: 'region', + Tag: 'tag', ZabbixConfigurationGroup: 'zabbixconfigurationgroup', } @@ -28,6 +33,9 @@ Device: 'device', VirtualMachine: 'virtualmachine', VirtualDeviceContext: 'virtualdevicecontext', + Site: 'site', + SiteGroup: 'sitegroup', + Region: 'region', ZabbixHostgroup: 'zabbixhostgroup', } diff --git a/nbxsync/constants/path_labels.py b/nbxsync/constants/path_labels.py index 1f2b1b41..7394d1d1 100644 --- a/nbxsync/constants/path_labels.py +++ b/nbxsync/constants/path_labels.py @@ -1,4 +1,12 @@ PATH_LABELS = { + ('device', 'site'): 'Site', + ('site',): 'Site', + ('site', 'group'): 'Site Group', + ('site', 'region'): 'Region', + ('region',): 'Region', + ('region', 'parent'): 'Region', + ('cluster', 'site'): 'Site', + ('cluster', '_site'): 'Site', ('device_type',): 'Device Type', ('device_type', 'manufacturer'): 'Manufacturer', ('zabbixconfigurationgroup',): 'Zabbix Configuration Group', diff --git a/nbxsync/forms/zabbixconfigurationgroupassignment.py b/nbxsync/forms/zabbixconfigurationgroupassignment.py index 987282e3..933b0226 100644 --- a/nbxsync/forms/zabbixconfigurationgroupassignment.py +++ b/nbxsync/forms/zabbixconfigurationgroupassignment.py @@ -7,8 +7,9 @@ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField, CSVModelChoiceField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext -from virtualization.models import VirtualMachine +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region +from extras.models import Tag +from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixConfigurationGroup, ZabbixConfigurationGroupAssignment @@ -22,7 +23,17 @@ class ZabbixConfigurationGroupAssignmentForm(NetBoxModelForm): device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False, selector=True, label=_('Device')) virtualdevicecontext = DynamicModelChoiceField(queryset=VirtualDeviceContext.objects.all(), required=False, selector=True, label=_('Virtual Device Context')) + devicetype = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False, selector=True, label=_('Device Type')) + role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False, selector=True, label=_('Device Role')) + manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) + platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) + clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) + tag = DynamicModelChoiceField(queryset=Tag.objects.all(), required=False, selector=True, label=_('Tag')) fieldsets = ( FieldSet('zabbixconfigurationgroup', name=_('Generic')), @@ -30,9 +41,19 @@ class ZabbixConfigurationGroupAssignmentForm(NetBoxModelForm): TabbedGroups( FieldSet('device', name=_('Device')), FieldSet('virtualdevicecontext', name=_('Virtual Device Context')), + FieldSet('devicetype', name=_('Device Type')), + FieldSet('role', name=_('Device Role')), + FieldSet('manufacturer', name=_('Manufacturer')), + FieldSet('platform', name=_('Platform')), FieldSet('virtualmachine', name=_('Virtual Machine')), + FieldSet('cluster', name=_('Cluster')), + FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), + FieldSet('tag', name=_('Tag')), ), - name=_('Device Assignment'), + name=_('Assignment'), ), ) @@ -43,6 +64,16 @@ class Meta: 'device', 'virtualdevicecontext', 'virtualmachine', + 'cluster', + 'clustertype', + 'devicetype', + 'role', + 'manufacturer', + 'platform', + 'site', + 'sitegroup', + 'region', + 'tag', ) @property @@ -86,7 +117,7 @@ def clean(self): elif selected_objects: self.instance.assigned_object = self.cleaned_data[selected_objects[0]] else: - self.instance.assigned_object = None + raise forms.ValidationError(_('A Zabbix Configuration Group must be assigned to an object.')) class ZabbixConfigurationGroupAssignmentFilterForm(NetBoxModelFilterSetForm): diff --git a/nbxsync/forms/zabbixhostgroupassignment.py b/nbxsync/forms/zabbixhostgroupassignment.py index 3500ea71..7e6c902e 100644 --- a/nbxsync/forms/zabbixhostgroupassignment.py +++ b/nbxsync/forms/zabbixhostgroupassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -25,7 +25,10 @@ class ZabbixHostgroupAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -42,6 +45,9 @@ class ZabbixHostgroupAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -53,6 +59,7 @@ class Meta: fields = ( 'zabbixhostgroup', 'device', + 'virtualdevicecontext', 'virtualmachine', 'cluster', 'clustertype', @@ -61,6 +68,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixhostinventory.py b/nbxsync/forms/zabbixhostinventory.py index 3f443997..d07fb8ca 100644 --- a/nbxsync/forms/zabbixhostinventory.py +++ b/nbxsync/forms/zabbixhostinventory.py @@ -6,8 +6,9 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext -from virtualization.models import VirtualMachine +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region +from virtualization.models import VirtualMachine, Cluster, ClusterType +from nbxsync.models import ZabbixConfigurationGroup from nbxsync.choices import ZabbixHostInventoryModeChoices from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -75,6 +76,16 @@ class ZabbixHostInventoryForm(NetBoxModelForm): device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False, selector=True, label=_('Device')) virtualdevicecontext = DynamicModelChoiceField(queryset=VirtualDeviceContext.objects.all(), required=False, selector=True, label=_('Virtual Device Context')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) + platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) + role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False, selector=True, label=_('Device Role')) + devicetype = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False, selector=True, label=_('Device Type')) + manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) + cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) + clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) + zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) fieldsets = ( FieldSet( @@ -178,8 +189,18 @@ class ZabbixHostInventoryForm(NetBoxModelForm): FieldSet( TabbedGroups( FieldSet('device', name=_('Device')), - FieldSet('virtualdevicecotext', name=_('Virtual Device Context')), + FieldSet('virtualdevicecontext', name=_('Virtual Device Context')), FieldSet('virtualmachine', name=_('Virtual Machine')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), + FieldSet('platform', name=_('Platform')), + FieldSet('role', name=_('Device Role')), + FieldSet('devicetype', name=_('Device Type')), + FieldSet('manufacturer', name=_('Manufacturer')), + FieldSet('cluster', name=_('Cluster')), + FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('zabbixconfigurationgroup', name=_('Config Group')), ), name=_('Assignment'), ), @@ -260,7 +281,18 @@ class Meta: 'name', 'notes', 'device', + 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', + 'platform', + 'role', + 'devicetype', + 'manufacturer', + 'cluster', + 'clustertype', + 'zabbixconfigurationgroup', ) @property diff --git a/nbxsync/forms/zabbixmacroassignment.py b/nbxsync/forms/zabbixmacroassignment.py index 353ca979..9f2f6597 100644 --- a/nbxsync/forms/zabbixmacroassignment.py +++ b/nbxsync/forms/zabbixmacroassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD @@ -26,6 +26,9 @@ class ZabbixMacroAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixMacroAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -66,6 +72,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixserverassignment.py b/nbxsync/forms/zabbixserverassignment.py index b2f5d1ea..ef46a6f9 100644 --- a/nbxsync/forms/zabbixserverassignment.py +++ b/nbxsync/forms/zabbixserverassignment.py @@ -7,7 +7,7 @@ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField, CSVModelChoiceField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext +from dcim.models import Device, VirtualDeviceContext, Site, SiteGroup, Region from virtualization.models import VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -27,6 +27,9 @@ class ZabbixServerAssignmentForm(NetBoxModelForm): device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False, selector=True, label=_('Device')) virtualdevicecontext = DynamicModelChoiceField(queryset=VirtualDeviceContext.objects.all(), required=False, selector=True, label=_('Virtual Device Context')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) fieldsets = ( FieldSet('zabbixserver', 'sync_enabled', name=_('Generic')), @@ -42,6 +45,9 @@ class ZabbixServerAssignmentForm(NetBoxModelForm): FieldSet('device', name=_('Device')), FieldSet('virtualdevicecontext', name=_('Virtual Device Context')), FieldSet('virtualmachine', name=_('Virtual Machine')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Device Assignment'), @@ -58,6 +64,9 @@ class Meta: 'device', 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', 'zabbixconfigurationgroup', ) diff --git a/nbxsync/forms/zabbixtagassignment.py b/nbxsync/forms/zabbixtagassignment.py index ed7c15ab..3c30afb0 100644 --- a/nbxsync/forms/zabbixtagassignment.py +++ b/nbxsync/forms/zabbixtagassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD @@ -26,6 +26,9 @@ class ZabbixTagAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixTagAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -63,6 +69,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixtemplateassignment.py b/nbxsync/forms/zabbixtemplateassignment.py index 274f2116..2c3b672a 100644 --- a/nbxsync/forms/zabbixtemplateassignment.py +++ b/nbxsync/forms/zabbixtemplateassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -26,6 +26,9 @@ class ZabbixTemplateAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixTemplateAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -63,6 +69,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/jobs/cfggroup.py b/nbxsync/jobs/cfggroup.py index 80fd2d65..4b741798 100644 --- a/nbxsync/jobs/cfggroup.py +++ b/nbxsync/jobs/cfggroup.py @@ -87,6 +87,10 @@ def __init__(self, **kwargs): def run(self): logger.debug('%s: deleting children for configgroup_pk=%s object_type_pk=%s object_id=%s', self.__class__.__name__, self.configgroup_pk, self.assigned_object_type_pk, self.assigned_object_id) + if not self.assigned_object_type_pk: + logger.warning('%s: missing assigned_object_type_pk – skipping', self.__class__.__name__) + return + try: assigned_ct = ContentType.objects.get(pk=self.assigned_object_type_pk) except ContentType.DoesNotExist: diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 321bb80c..8462a063 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -1,9 +1,9 @@ +import copy import logging from django.contrib.contenttypes.models import ContentType from nbxsync.choices.zabbixstatus import ZabbixHostStatus -from nbxsync.models import ZabbixServerAssignment from nbxsync.settings import get_plugin_settings from nbxsync.utils import get_assigned_zabbixobjects from nbxsync.utils.sync import HostGroupSync, HostInterfaceSync, HostSync, ProxyGroupSync, ProxySync, run_zabbix_operation @@ -20,10 +20,49 @@ class SyncHostJob: def __init__(self, **kwargs): self.instance = kwargs.get('instance') # This is the Device or VirtualMachine object - def run(self): - object_ct = ContentType.objects.get_for_model(self.instance) + def _prepare_assignment(self, assignment): + """ + If the assignment is inherited (not directly on this device/VM), + create a detached copy so that hostid and sync metadata cannot be + persisted back to the Site-level (or Platform-level) assignment row. + + Uses pk=None (Django idiom for "new object") so any accidental save() + would INSERT rather than UPDATE the original row. The _is_inherited_copy + flag is checked by SyncBase.sync() to skip save() entirely. + """ + instance_ct = ContentType.objects.get_for_model(self.instance) + is_direct = assignment.assigned_object_type_id == instance_ct.id and assignment.assigned_object_id == self.instance.pk + if not is_direct: + assignment = copy.copy(assignment) + assignment.pk = None + assignment._is_inherited_copy = True + return assignment + + def _is_excluded(self, pluginsettings, all_objects): + """Check whether this device/VM should be excluded from Zabbix sync. + + When a ZabbixTag with the configured ``exclude_tag`` name (default: + empty string — disabled) is assigned to a DeviceRole, Platform, Site, + Manufacturer, ConfigGroup, or directly to the Device/VM, every host + that inherits from that object is excluded. The tag is never pushed + to Zabbix — it is only used as a signal during sync resolution. + + Returns True if the host should be excluded. + """ + exclude_tag = pluginsettings.exclude_tag + if not exclude_tag: + return False + + for tag_assignment in all_objects.get('tags', []): + if tag_assignment.zabbixtag.tag == exclude_tag: + logger.debug('Excluding %s: exclude tag "%s" present', self.instance, exclude_tag) + return True + + return False - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.instance.pk) + def run(self): + all_objects = get_assigned_zabbixobjects(self.instance) + zabbixserver_assignments = all_objects.get('server_assignments', []) status = self.instance.status object_type = self.instance._meta.model_name # "device" or "virtualmachine" @@ -31,10 +70,24 @@ def run(self): status_mapping = getattr(pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) + # --- Exclusion check --- + if self._is_excluded(pluginsettings, all_objects): + # Still delete the host from Zabbix if it was previously synced + if zabbix_status != ZabbixHostStatus.DELETED: + for assignment in zabbixserver_assignments: + if assignment.sync_enabled and assignment.zabbixserver.sync_enabled: + assignment = self._prepare_assignment(assignment) + self.delete_host(assignment) + logger.info('Skipping sync for %s (excluded)', self.instance) + return for assignment in zabbixserver_assignments: if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: continue + # Detach inherited assignments so hostid/sync-info is not written + # back to the source row (e.g. a Site-level assignment). + assignment = self._prepare_assignment(assignment) + if zabbix_status == ZabbixHostStatus.DELETED: self.delete_host(assignment) else: @@ -42,21 +95,24 @@ def run(self): self.sync_host(assignment) self.verify_hostinterfaces(assignment) - if object_type == 'device' and zabbix_status != ZabbixHostStatus.DELETED and pluginsettings.trigger_dependencies.enabled: - try: - sync_device_trigger_dependencies(self.instance) - except Exception: - logger.exception('Trigger dependency sync failed for %s; continuing.', self.instance) + trigger_config = getattr(pluginsettings, 'trigger_dependencies', None) + if object_type == 'device' and zabbix_status != ZabbixHostStatus.DELETED and trigger_config is not None and trigger_config.enabled: + try: + sync_device_trigger_dependencies(self.instance) + except Exception: + logger.exception('Trigger dependency sync failed for %s; continuing.', self.instance) def delete_host(self, assignment): safe_delete(HostSync, assignment) def verify_hostinterfaces(self, assignment): all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) + all_objects['_instance'] = self.instance run_zabbix_operation(HostSync, assignment, 'verify_hostinterfaces', extra_args={'all_objects': all_objects}) def check_default_hostinterface(self, assignment): all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) + all_objects['_instance'] = self.instance run_zabbix_operation(HostSync, assignment, 'check_default_hostinterface', extra_args={'all_objects': all_objects}) def sync_host(self, assignment): @@ -64,6 +120,7 @@ def sync_host(self, assignment): all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) # Add the assigned_objects attribute, so we dont have to do this expensive calculation again later on :) assignment.assigned_objects = all_objects + all_objects['_instance'] = self.instance # Create all hostgroups for hostgroup in all_objects['hostgroups']: @@ -88,7 +145,7 @@ def sync_host(self, assignment): # This can happen, in cases where the host exists, a new HostInterface is added (SNMP for example) and a new template (which requires SNMP) # In such cases, the Host Update will fail, due to the Interface not existing yet. # Fail silently, so we can create the interface - and we'll sync the template on the next run... - pass + logger.warning(f'Initial HostSync failed for {self.instance}: {e}') # Once the Host exists and we have a HostId, time to sync the interfaces # Sort by: @@ -97,7 +154,15 @@ def sync_host(self, assignment): # - id hostinterfaces_sorted = sorted(all_objects['hostinterfaces'], key=lambda hostinterface: (-int(hostinterface.interface_type == 1), hostinterface.type, hostinterface.id)) for hostinterface in hostinterfaces_sorted: - safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': assignment.hostid}) + try: + safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': assignment.hostid, '_instance': self.instance}) + except Exception as e: + # Continue syncing remaining interfaces even if one fails. + # A common case: device inherits both Agent and SNMP + # interfaces but the SNMP credentials are wrong — this + # should not prevent the Agent interface and templates + # from being synced. + logger.warning(f'HostInterfaceSync failed for {self.instance}: {e}') safe_sync(HostSync, assignment, extra_args={'all_objects': all_objects}) diff --git a/nbxsync/migrations/0013_alter_zabbixhostgroupassignment_assigned_object_type_and_more.py b/nbxsync/migrations/0013_alter_zabbixhostgroupassignment_assigned_object_type_and_more.py new file mode 100644 index 00000000..4a263e41 --- /dev/null +++ b/nbxsync/migrations/0013_alter_zabbixhostgroupassignment_assigned_object_type_and_more.py @@ -0,0 +1,216 @@ +# Generated by Django 5.2.13 on 2026-07-14 23:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('nbxsync', '0012_zabbixserver_skip_version_check'), + ] + + operations = [ + migrations.AlterField( + model_name='zabbixhostgroupassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixhostinventory', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixmacroassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixserverassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixtagassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixtemplateassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixconfigurationgroupassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ) + ] diff --git a/nbxsync/migrations/0014_tag_assignment_targets.py b/nbxsync/migrations/0014_tag_assignment_targets.py new file mode 100644 index 00000000..0170eea8 --- /dev/null +++ b/nbxsync/migrations/0014_tag_assignment_targets.py @@ -0,0 +1,242 @@ +# Generated by Django 5.2.13 on 2026-08-03 13:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Tag assignment targets + HostInterface Tag templates (squashed).""" + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('nbxsync', '0013_alter_zabbixhostgroupassignment_assigned_object_type_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='zabbixconfigurationgroupassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixhostgroupassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixhostinventory', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixmacroassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixserverassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixtagassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixtemplateassignment', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), + models.Q(('app_label', 'dcim'), ('model', 'site')), + models.Q(('app_label', 'dcim'), ('model', 'sitegroup')), + models.Q(('app_label', 'dcim'), ('model', 'region')), + models.Q(('app_label', 'dcim'), ('model', 'manufacturer')), + models.Q(('app_label', 'dcim'), ('model', 'devicerole')), + models.Q(('app_label', 'dcim'), ('model', 'devicetype')), + models.Q(('app_label', 'dcim'), ('model', 'platform')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + models.Q(('app_label', 'virtualization'), ('model', 'cluster')), + models.Q(('app_label', 'virtualization'), ('model', 'clustertype')), + _connector='OR', + ) + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='zabbixhostinterface', + name='assigned_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q(models.Q(('app_label', 'dcim'), ('model', 'device')), models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), _connector='OR'), + models.Q(('app_label', 'nbxsync'), ('model', 'zabbixconfigurationgroup')), + models.Q(('app_label', 'extras'), ('model', 'tag')), + _connector='OR', + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='+', + to='contenttypes.contenttype', + ), + ), + ] diff --git a/nbxsync/models/sync_info.py b/nbxsync/models/sync_info.py index 6e620f86..3b09eb0b 100644 --- a/nbxsync/models/sync_info.py +++ b/nbxsync/models/sync_info.py @@ -20,6 +20,11 @@ def update_sync_info(self, success: bool, message: str = ''): success (bool): Whether the sync was successful. message (str): Optional message to describe the sync outcome. """ + # Skip persistence for inherited copies (pk=None) — these are transient + # copies of assignments from Site/Platform/etc. and must not write back. + if getattr(self, '_is_inherited_copy', False): + return + if success and message == '': message = 'Synchronization successful' diff --git a/nbxsync/models/zabbixconfigurationgroupassignment.py b/nbxsync/models/zabbixconfigurationgroupassignment.py index 55cf6648..25c584f6 100644 --- a/nbxsync/models/zabbixconfigurationgroupassignment.py +++ b/nbxsync/models/zabbixconfigurationgroupassignment.py @@ -4,7 +4,7 @@ from netbox.models import NetBoxModel -from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS __all__ = ('ZabbixConfigurationGroupAssignment',) @@ -12,7 +12,7 @@ class ZabbixConfigurationGroupAssignment(NetBoxModel): zabbixconfigurationgroup = models.ForeignKey('nbxsync.ZabbixConfigurationGroup', on_delete=models.CASCADE, related_name='zabbixconfigurationgroupassignment') - assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=DEVICE_OR_VM_ASSIGNMENT_MODELS, on_delete=models.CASCADE, related_name='+', blank=True, null=True) + assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=ASSIGNMENT_MODELS, on_delete=models.CASCADE, related_name='+', blank=True, null=True) assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id') diff --git a/nbxsync/models/zabbixhostinterface.py b/nbxsync/models/zabbixhostinterface.py index 3e476959..602c2426 100644 --- a/nbxsync/models/zabbixhostinterface.py +++ b/nbxsync/models/zabbixhostinterface.py @@ -7,9 +7,10 @@ from django.db import models from django.db.models import Q from django.utils.translation import gettext_lazy as _ +from ipam.models import IPAddress +from extras.models import Tag from netbox.models import NetBoxModel -from ipam.models import IPAddress from utilities.jinja2 import render_jinja2 from nbxsync.choices import ( @@ -24,8 +25,7 @@ ZabbixInterfaceUseChoices, ZabbixTLSChoices, ) - -from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants.assignment_models import CONFIGGROUP_OBJECTS, DEVICE_OR_VM_ASSIGNMENT_MODELS, TAG_OBJECTS from nbxsync.constants.template_pattern import TEMPLATE_PATTERN from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup @@ -46,7 +46,7 @@ class ZabbixHostInterface(SyncInfoModel, NetBoxModel): ip = models.ForeignKey(to=IPAddress, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('IP Address'), related_name='zabbix_hostinterfaces') port = models.IntegerField(blank=False, null=False, verbose_name=_('Port number')) - assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=(DEVICE_OR_VM_ASSIGNMENT_MODELS | CONFIGGROUP_OBJECTS), on_delete=models.CASCADE, related_name='+', blank=True, null=True) + assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=(DEVICE_OR_VM_ASSIGNMENT_MODELS | CONFIGGROUP_OBJECTS | TAG_OBJECTS), on_delete=models.CASCADE, related_name='+', blank=True, null=True) assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id') @@ -128,7 +128,7 @@ def render_dns(self, **context): def dns_is_template(self): return bool(TEMPLATE_PATTERN.search(self.dns)) - def clean(self): + def clean(self): # noqa: C901 — validation ladder, flat reads better than split super().clean() errors = {} @@ -159,8 +159,13 @@ def clean(self): if self.snmpv3_privacy_passphrase and len(self.snmpv3_privacy_passphrase) < 8: errors['snmpv3_privacy_passphrase'] = _('Privacy passphrase must be at least 8 characters long.') - # If the assigned object type is *not* a ZabbixConfigurationGroup, we validate the IP and/or DNS entry - if self.assigned_object_type != ContentType.objects.get_for_model(ZabbixConfigurationGroup): + # Template-like targets carry no fixed endpoint; the sync engine + # resolves the interface IP/DNS from each concrete device at sync time. + template_target_cts = {ContentType.objects.get_for_model(ZabbixConfigurationGroup), ContentType.objects.get_for_model(Tag)} + is_template_target = self.assigned_object_type in template_target_cts + + # If the assigned object type is *not* a template target, we validate the IP and/or DNS entry + if not is_template_target: # Validate based on connection method if self.useip == ZabbixInterfaceUseChoices.IP: if not self.ip: @@ -170,9 +175,9 @@ def clean(self): if not self.dns: errors['dns'] = _('A DNS name is required when "Connect via" is set to DNS.') - # # If ZbxConfigGroup, ensure neither IP and DNS are set, as we cannot support this - # # The IP will be set upon assignment! - if self.assigned_object_type == ContentType.objects.get_for_model(ZabbixConfigurationGroup): + # Template-like rows are endpoint-free; the IP is set upon resolution + # for each concrete device at sync time. + if is_template_target: self.ip = None # Only set DNS to '' when its NOT a template if not self.dns_is_template(): @@ -199,7 +204,7 @@ def get_tls_connect_display(self): def get_tls_accept_display(self): return [ZabbixTLSChoices(value).label for value in self.tls_accept if value in ZabbixTLSChoices.values] - def get_ipmi_privilege_display(self): + def get_ipmi_privlege_display(self): return IPMIPrivilegeChoices(self.ipmi_privilege).label def get_ipmi_authtype_display(self): @@ -214,5 +219,5 @@ def get_snmpv3_security_level_display(self): def get_snmpv3_authentication_protocol_display(self): return ZabbixInterfaceSNMPV3AuthProtoChoices(self.snmpv3_authentication_protocol).label - def get_snmpv3_privacy_protocol_display(self): + def get_snmpv3_snmpv3_privacy_protocol_display(self): return ZabbixInterfaceSNMPV3PrivProtoChoices(self.snmpv3_privacy_protocol).label diff --git a/nbxsync/models/zabbixhostinventory.py b/nbxsync/models/zabbixhostinventory.py index efe877d7..6152f0a5 100644 --- a/nbxsync/models/zabbixhostinventory.py +++ b/nbxsync/models/zabbixhostinventory.py @@ -11,7 +11,7 @@ from utilities.jinja2 import render_jinja2 from nbxsync.choices import ZabbixHostInventoryModeChoices -from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS __all__ = ('ZabbixHostInventory',) @@ -91,7 +91,7 @@ class ZabbixHostInventory(NetBoxModel): url_c = models.CharField(max_length=2048, blank=True, verbose_name=_('URL C')) vendor = models.CharField(max_length=64, blank=True, verbose_name=_('Vendor')) - assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=DEVICE_OR_VM_ASSIGNMENT_MODELS, on_delete=models.CASCADE, related_name='+', blank=True, null=True) + assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=(ASSIGNMENT_MODELS | CONFIGGROUP_OBJECTS), on_delete=models.CASCADE, related_name='+', blank=True, null=True) assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id') diff --git a/nbxsync/settings.py b/nbxsync/settings.py index 4d407ff7..7f73582f 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -52,7 +52,7 @@ def validate_macro_format(cls, v: str) -> str: class BackgroundSyncConfig(BaseModel): enabled: bool = Field(default=True) - interval: int = Field(default=60) + interval: int = Field(default=360) class BackgroundSync(BaseModel): @@ -134,6 +134,9 @@ class PluginSettingsModel(BaseModel): backgroundsync: BackgroundSync = Field(default_factory=BackgroundSync) trigger_dependencies: TriggerDependencyConfig = Field(default_factory=TriggerDependencyConfig) inheritance_chain: List[Tuple[str, ...]] = Field( + # Leaf-first. Existing role/platform paths stay ahead of Site hierarchy so + # adding Site/SiteGroup/Region inheritance does not override Role/Platform + # assignments on upgrade. Cluster site uses CachedScopeMixin._site (NetBox ≥4.2). default_factory=lambda: [ ('device',), ('role',), @@ -180,6 +183,24 @@ class PluginSettingsModel(BaseModel): 'type', ), ('type',), + # Hierarchy targets for zero-touch (appended after device/role/platform) + ( + 'device', + 'site', + ), # VirtualDeviceContext → device → site + ('site',), + ( + 'site', + 'group', + ), + ( + 'site', + 'region', + ), + ( + 'cluster', + '_site', + ), # NetBox ≥4.2 Cluster scope cache (not .site) ] ) no_alerting_tag: str = Field(default='NO_ALERTING') @@ -192,6 +213,25 @@ class PluginSettingsModel(BaseModel): custom_field_hostname: str = Field(default='') custom_field_display_name: str = Field(default='') + # Tag name that, when assigned (inherited or direct) to a Device/VM, + # excludes the host from Zabbix sync entirely. Uses the same inheritance + # chain as templates, hostgroups, and other tag assignments. + exclude_tag: str = Field(default='') + + # Deleting a host in Zabbix destroys its history, so inheritance-driven + # deletions from lost server assignments are opt-in. While disabled, + # nbxsync logs the hosts it would have deleted when their assignment + # disappeared and leaves them untouched, which lets operators review the + # impact of e.g. a moved Site before any data is lost. Explicit operator + # decisions (exclude tags, status mapped to deleted, NetBox deletion) + # always delete. + allow_inherited_deletion: bool = Field(default=False) + + # Bind to a pre-existing Zabbix host that carries matching nb_type/nb_id + # tags instead of failing. Off by default: adopting a host that nbxsync did + # not create means NetBox immediately starts overwriting its configuration. + adopt_existing_hosts: bool = Field(default=False) + # Helper function def get_plugin_settings() -> PluginSettingsModel: diff --git a/nbxsync/signals/zabbixconfigurationgroupassignment.py b/nbxsync/signals/zabbixconfigurationgroupassignment.py index 0402f599..9be92dca 100644 --- a/nbxsync/signals/zabbixconfigurationgroupassignment.py +++ b/nbxsync/signals/zabbixconfigurationgroupassignment.py @@ -9,7 +9,7 @@ @receiver(post_save, sender=ZabbixConfigurationGroupAssignment) def handle_postsave_zabbixconfigurationgroupassignment(sender, instance, created, **kwargs): - if instance.zabbixconfigurationgroup is None: + if instance.zabbixconfigurationgroup is None or instance.assigned_object_type_id is None: return propagate_configgroup_assignment.delay(instance.pk) @@ -17,7 +17,7 @@ def handle_postsave_zabbixconfigurationgroupassignment(sender, instance, created @receiver(post_delete, sender=ZabbixConfigurationGroupAssignment) def handle_postdelete_zabbixconfigurationgroupassignment(sender, instance, **kwargs): - if instance.zabbixconfigurationgroup is None: + if instance.zabbixconfigurationgroup is None or instance.assigned_object_type_id is None: return delete_configgroup_assignment_children.delay(configgroup_pk=instance.zabbixconfigurationgroup.pk, assigned_object_type_pk=instance.assigned_object_type_id, assigned_object_id=instance.assigned_object_id) diff --git a/nbxsync/tables/zabbixserverassignment.py b/nbxsync/tables/zabbixserverassignment.py index b9a3bf56..f643fa2b 100644 --- a/nbxsync/tables/zabbixserverassignment.py +++ b/nbxsync/tables/zabbixserverassignment.py @@ -1,16 +1,48 @@ -import django_tables2 as tables -from django_tables2.utils import A +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ +import django_tables2 as tables +from django_tables2.utils import A from netbox.tables import NetBoxTable +from nbxsync.constants import ADD_HOSTINTERFACE_BUTTON from nbxsync.models import ZabbixServerAssignment from nbxsync.tables.columns import ContentTypeModelNameColumn, InheritanceAwareActionsColumn -from nbxsync.constants import ADD_HOSTINTERFACE_BUTTON __all__ = ('ZabbixServerAssignmentTable', 'ZabbixServerAssignmentObjectViewTable') +class InheritedSyncStatusColumn(tables.Column): + """Renders sync status, showing neutral indicator for inherited assignments.""" + + def render(self, record): + # Inherited assignments are read-only copies — their sync status is + # not persisted (by design). Show a neutral indicator instead of + # misleading red X for "Never synced". + if getattr(record, '_inherited_from', None): + return format_html( + '', + record._inherited_from, + ) + + if record.last_sync_state: + return format_html( + '', + record.last_sync.strftime('%d-%m-%Y %H:%M') if record.last_sync else '', + record.last_sync_message, + ) + if record.last_sync: + return format_html( + '', + record.last_sync.strftime('%d-%m-%Y %H:%M'), + record.last_sync_message, + ) + return format_html( + '', + record.last_sync_message, + ) + + class ZabbixServerAssignmentTable(NetBoxTable): assigned_object = tables.Column(verbose_name=_('Assigned to'), linkify=True, orderable=False) assigned_object_type = ContentTypeModelNameColumn(accessor='assigned_object_type', verbose_name=_('Object Type'), order_by=('assigned_object_type__model',)) @@ -28,6 +60,7 @@ class Meta(NetBoxTable.Meta): 'zabbixserver', 'zabbixproxy', 'zabbixproxygroup', + 'sync_status', 'sync_enabled', 'created', 'last_updated', @@ -40,7 +73,9 @@ class Meta(NetBoxTable.Meta): 'zabbixserver', 'zabbixproxy', 'zabbixproxygroup', + 'sync_status', 'sync_enabled', + 'actions', ) @@ -50,36 +85,21 @@ class ZabbixServerAssignmentObjectViewTable(NetBoxTable): zabbixserver = tables.Column(accessor='zabbixserver.name', verbose_name=_('Zabbix Server'), linkify={'viewname': 'plugins:nbxsync:zabbixserver', 'args': [A('zabbixserver.pk')]}) zabbixproxy = tables.Column(accessor='zabbixproxy.name', verbose_name=_('Zabbix Proxy'), linkify={'viewname': 'plugins:nbxsync:zabbixproxy', 'args': [A('zabbixproxy.pk')]}) zabbixproxygroup = tables.Column(accessor='zabbixproxygroup.name', verbose_name=_('Zabbix Proxygroup'), linkify={'viewname': 'plugins:nbxsync:zabbixproxygroup', 'args': [A('zabbixproxygroup.pk')]}) - sync_status = tables.TemplateColumn( - template_code=""" - {% if record.last_sync_state %} - - {% else %} - {% if record.last_sync %} - - {% else %} - - {% endif %} - {% endif %} - """, - orderable=False, - ) - + sync_status = InheritedSyncStatusColumn(accessor=tables.A('pk'), verbose_name=_('Sync status'), orderable=False) sync_enabled = tables.TemplateColumn( template_code=""" {% if record.sync_enabled %} {% if record.zabbixserver.sync_enabled %} - + {% else %} - + {% endif %} {% else %} - + {% endif %} """, orderable=False, ) - actions = InheritanceAwareActionsColumn(extra_buttons=ADD_HOSTINTERFACE_BUTTON) class Meta(NetBoxTable.Meta): diff --git a/nbxsync/template_content.py b/nbxsync/template_content.py index 6dc66305..20780065 100644 --- a/nbxsync/template_content.py +++ b/nbxsync/template_content.py @@ -1,9 +1,5 @@ -from django.contrib.contenttypes.models import ContentType - from netbox.plugins import PluginTemplateExtension - -from nbxsync.models import ZabbixHostInterface, ZabbixServerAssignment, ZabbixTemplateAssignment from nbxsync.choices import HostInterfaceRequirementChoices, ZabbixInterfaceTypeChoices from nbxsync.utils import get_assigned_zabbixobjects, get_maintenance_can_sync @@ -53,14 +49,14 @@ def buttons(self): if not obj: return '' - ct = ContentType.objects.get_for_model(obj) - has_server_assignment = ZabbixServerAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=obj.pk).exists() + assigned = get_assigned_zabbixobjects(obj) + has_server_assignment = bool(assigned.get('server_assignments')) - hostgroups = get_assigned_zabbixobjects(obj).get('hostgroups') or [] - has_hostgroup_assignment = bool(hostgroups) + has_hostgroup_assignment = bool(assigned.get('hostgroups')) - assigned_hostinterface_types = set(ZabbixHostInterface.objects.filter(assigned_object_type=ct, interface_type=ZabbixInterfaceTypeChoices.DEFAULT, assigned_object_id=obj.pk).values_list('type', flat=True).distinct()) - assigned_zabbixtemplates = list(ZabbixTemplateAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=obj.pk)) + hostinterfaces = assigned.get('hostinterfaces') or [] + assigned_hostinterface_types = set(hi.type for hi in hostinterfaces if int(getattr(hi, 'interface_type', 0)) == ZabbixInterfaceTypeChoices.DEFAULT) + assigned_zabbixtemplates = assigned.get('templates') or [] has_hostinterface_assignment = True diff --git a/nbxsync/templates/nbxsync/tabs/minimal.html b/nbxsync/templates/nbxsync/tabs/minimal.html index da94da4e..0e5b1d3b 100644 --- a/nbxsync/templates/nbxsync/tabs/minimal.html +++ b/nbxsync/templates/nbxsync/tabs/minimal.html @@ -7,7 +7,64 @@ {% load render_table from django_tables2 %} {% load plugins %} {% block content %} - {% block extra_cards %}{% endblock %} + {% block extra_cards %} +
+
+
+

+ Zabbix Servers + {% if perms.nbxsync.add_zabbixserverassignment %} + + {% endif %} +

+ {% if zabbixserver_assignments_table %} + {% render_table zabbixserver_assignments_table %} + {% else %} +
No Zabbix Servers assigned
+ {% endif %} +
+
+
+
+

Miscellaneous

+
+ + + + + + + + + + +
Host Inventory + {% if hostinventory_assignment %} + Host Inventory of {{ hostinventory_assignment.assigned_object }} + {% else %} + + Add + + {% endif %} +
Configuration Group + {% if configurationgroup_assignment %} + {{ configurationgroup_assignment.zabbixconfigurationgroup }} + {% else %} + + Add + + {% endif %} +
+
+
+
+
+ {% endblock %}
diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index ad55df94..6dd274e0 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -4,7 +4,7 @@ from django.test import TestCase from ipam.models import IPAddress -from dcim.models import Device +from dcim.models import Device, Site from utilities.testing import create_test_device from nbxsync.choices import ZabbixProxyTypeChoices, ZabbixTLSChoices @@ -200,6 +200,12 @@ def side_effect(sync_class, *args, **kwargs): mock_gao.return_value = { 'hostgroups': [], 'hostinterfaces': [self.hostinterface], + 'server_assignments': [self.zabbixserverassignment], + 'templates': [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, } job.run() @@ -240,3 +246,73 @@ def test_run_skips_sync_when_both_assignment_and_zabbixserver_sync_disabled(self job.run() mock_safe_sync.assert_not_called() + + def test_prepare_assignment_returns_original_for_direct(self): + job = SyncHostJob(instance=self.device) + + prepared = job._prepare_assignment(self.zabbixserverassignment) + + self.assertEqual(prepared.pk, self.zabbixserverassignment.pk) + self.assertFalse(getattr(prepared, '_is_inherited_copy', False)) + + def test_prepare_assignment_copies_inherited(self): + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + site_assignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + ) + + job = SyncHostJob(instance=self.device) + prepared = job._prepare_assignment(site_assignment) + + self.assertIsNone(prepared.pk) + self.assertTrue(getattr(prepared, '_is_inherited_copy', False)) + # Original assignment is untouched + self.assertIsNotNone(site_assignment.pk) + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + @patch.object(SyncHostJob, 'check_default_hostinterface') + def test_run_syncs_inherited_assignment_from_site(self, mock_check, mock_verify, mock_safe_sync): + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + # Remove the direct assignment so only inherited remains + self.zabbixserverassignment.delete() + + ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + # safe_sync should have been called (host groups, proxy, host, interfaces) + self.assertTrue(mock_safe_sync.called) + + @patch('nbxsync.jobs.synchost.safe_sync') + def test_run_continues_when_assignment_sync_disabled(self, mock_safe_sync): + # Create two assignments: one disabled (site-level), one enabled (direct) + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + sync_enabled=False, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + # Direct assignment is enabled, so safe_sync should still be called + self.assertTrue(mock_safe_sync.called) diff --git a/nbxsync/tests/jobs/test_synchost_exclusion.py b/nbxsync/tests/jobs/test_synchost_exclusion.py new file mode 100644 index 00000000..644b3a29 --- /dev/null +++ b/nbxsync/tests/jobs/test_synchost_exclusion.py @@ -0,0 +1,210 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from ipam.models import IPAddress + +from dcim.models import Device +from utilities.testing import create_test_device + +from nbxsync.choices import ZabbixProxyTypeChoices, ZabbixTLSChoices +from nbxsync.jobs.synchost import SyncHostJob +from nbxsync.models import ( + ZabbixHostgroup, + ZabbixHostgroupAssignment, + ZabbixHostInterface, + ZabbixProxy, + ZabbixServer, + ZabbixServerAssignment, + ZabbixTag, + ZabbixTagAssignment, +) + + +class SyncHostExclusionTestCase(TestCase): + """Tests for tag-based host exclusion (_is_excluded and exclude_tag).""" + + def setUp(self): + self.device = create_test_device(name='ExcludeTestDevice') + self.device_ct = ContentType.objects.get_for_model(Device) + + self.zabbixserver = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123') + self.proxy = ZabbixProxy.objects.create( + name='Test Proxy', + zabbixserver=self.zabbixserver, + operating_mode=ZabbixProxyTypeChoices.ACTIVE, + local_address='192.168.1.1', + local_port=10051, + allowed_addresses=['10.0.0.1'], + tls_accept=[ZabbixTLSChoices.PSK], + tls_psk_identity='psk-id', + tls_psk='2AB09AD2496109A3BFAC0C6BB4D37CEF', + ) + self.interface_ip = IPAddress.objects.create(address='192.168.1.100/32') + self.hostinterface = ZabbixHostInterface.objects.create( + zabbixserver=self.zabbixserver, + type=1, + interface_type=1, + useip=1, + dns='', + ip=self.interface_ip, + port=10050, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + self.hostgroup = ZabbixHostgroup.objects.create(name='HG1', zabbixserver=self.zabbixserver, groupid=123) + self.zabbixserverassignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + hostid='12345', + zabbixproxy=self.proxy, + ) + self.zabbixhostgroupassignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=self.hostgroup, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + + # Patch ZabbixConnection to avoid real HTTP calls + self.zabbix_patcher = patch('nbxsync.utils.sync.run_zabbix_operations.ZabbixConnection') + mock_conn_class = self.zabbix_patcher.start() + self.addCleanup(self.zabbix_patcher.stop) + + mock_api = MagicMock() + mock_api.host.get.return_value = [{'hostid': '12345'}] + mock_api.host.create.return_value = {'hostids': ['12345']} + mock_api.host.update.return_value = {'hostids': ['12345']} + mock_api.host.delete.return_value = True + mock_api.hostinterface.get.return_value = [] + mock_api.hostinterface.create.return_value = {'interfaceids': ['999']} + mock_api.proxy.get.return_value = [ + { + 'proxyid': '42', + 'host': 'Test Proxy', + 'status': '5', + 'description': 'Desc', + 'tls_accept': '1', + 'tls_connect': '1', + 'tls_psk': 'psk', + 'tls_psk_identity': 'id', + 'proxy_groupid': '0', + 'local_address': '192.168.1.1', + 'local_port': '10051', + 'allowed_addresses': '10.0.0.1', + 'address': '127.0.0.1', + 'port': '10051', + } + ] + mock_api.hostgroup.get.return_value = [{'groupid': '1'}] + mock_conn_class.return_value.__enter__.return_value = mock_api + + def _create_exclude_tag(self): + return ZabbixTag.objects.create(tag='do_not_monitor', name='Do Not Monitor', value='') + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + def test_is_excluded_returns_false_when_exclude_tag_empty(self, mock_settings): + mock_settings.return_value.exclude_tag = '' + job = SyncHostJob(instance=self.device) + all_objects = {'tags': []} + self.assertFalse(job._is_excluded(mock_settings.return_value, all_objects)) + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + def test_is_excluded_returns_false_when_tag_not_present(self, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + job = SyncHostJob(instance=self.device) + all_objects = {'tags': []} + self.assertFalse(job._is_excluded(mock_settings.return_value, all_objects)) + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + def test_is_excluded_returns_true_when_tag_present(self, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + exclude_tag = self._create_exclude_tag() + tag_assignment = ZabbixTagAssignment.objects.create( + zabbixtag=exclude_tag, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + tag_assignment._inherited_from = 'Device' + + job = SyncHostJob(instance=self.device) + all_objects = {'tags': [tag_assignment]} + self.assertTrue(job._is_excluded(mock_settings.return_value, all_objects)) + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + @patch('nbxsync.jobs.synchost.safe_sync') + def test_run_skips_sync_when_excluded(self, mock_safe_sync, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + mock_settings.return_value.statusmapping = MagicMock() + mock_settings.return_value.statusmapping.device = {'active': 'enabled'} + mock_settings.return_value.statusmapping.virtualmachine = {} + + exclude_tag = self._create_exclude_tag() + ZabbixTagAssignment.objects.create( + zabbixtag=exclude_tag, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + mock_safe_sync.assert_not_called() + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + @patch.object(SyncHostJob, 'check_default_hostinterface') + def test_run_syncs_when_not_excluded(self, mock_check, mock_verify, mock_safe_sync, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + mock_settings.return_value.statusmapping = MagicMock() + mock_settings.return_value.statusmapping.device = {'active': 'enabled'} + mock_settings.return_value.statusmapping.virtualmachine = {} + + job = SyncHostJob(instance=self.device) + job.run() + + mock_safe_sync.assert_called() + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + @patch('nbxsync.jobs.synchost.safe_delete') + def test_run_deletes_host_when_excluded_and_not_already_deleted(self, mock_safe_delete, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + mock_settings.return_value.statusmapping = MagicMock() + mock_settings.return_value.statusmapping.device = {'active': 'enabled'} + mock_settings.return_value.statusmapping.virtualmachine = {} + + exclude_tag = self._create_exclude_tag() + ZabbixTagAssignment.objects.create( + zabbixtag=exclude_tag, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + mock_safe_delete.assert_called() + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + @patch('nbxsync.jobs.synchost.safe_delete') + def test_run_does_not_delete_when_excluded_and_status_deleted(self, mock_safe_delete, mock_settings): + mock_settings.return_value.exclude_tag = 'do_not_monitor' + mock_settings.return_value.statusmapping = MagicMock() + mock_settings.return_value.statusmapping.device = {'decommissioning': 'deleted'} + mock_settings.return_value.statusmapping.virtualmachine = {} + + self.device.status = 'decommissioning' + self.device.save() + + exclude_tag = self._create_exclude_tag() + ZabbixTagAssignment.objects.create( + zabbixtag=exclude_tag, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + mock_safe_delete.assert_not_called() diff --git a/nbxsync/tests/signals/test_zabbixconfigurationgroupassignment.py b/nbxsync/tests/signals/test_zabbixconfigurationgroupassignment.py index 3806cec6..72c8a537 100644 --- a/nbxsync/tests/signals/test_zabbixconfigurationgroupassignment.py +++ b/nbxsync/tests/signals/test_zabbixconfigurationgroupassignment.py @@ -12,9 +12,8 @@ def setUp(self): self.cfg = ZabbixConfigurationGroup.objects.create(name='Test Config Group', description='Signal test cfg group') @patch('nbxsync.signals.zabbixconfigurationgroupassignment.propagate_configgroup_assignment') - def test_postsave_enqueues_job_when_configgroup_present(self, mock_job): - asn = ZabbixConfigurationGroupAssignment(zabbixconfigurationgroup=self.cfg, assigned_object_type=None, assigned_object_id=None) - asn.pk = 42 + def test_postsave_enqueues_job_when_configgroup_and_target_present(self, mock_job): + asn = SimpleNamespace(pk=42, zabbixconfigurationgroup=self.cfg, assigned_object_type_id=99) handle_postsave_zabbixconfigurationgroupassignment(sender=ZabbixConfigurationGroupAssignment, instance=asn, created=True) @@ -22,7 +21,16 @@ def test_postsave_enqueues_job_when_configgroup_present(self, mock_job): @patch('nbxsync.signals.zabbixconfigurationgroupassignment.propagate_configgroup_assignment') def test_postsave_returns_early_when_configgroup_none(self, mock_job): - asn = SimpleNamespace(zabbixconfigurationgroup=None) + asn = SimpleNamespace(zabbixconfigurationgroup=None, assigned_object_type_id=99) + + handle_postsave_zabbixconfigurationgroupassignment(sender=ZabbixConfigurationGroupAssignment, instance=asn, created=True) + + mock_job.delay.assert_not_called() + + @patch('nbxsync.signals.zabbixconfigurationgroupassignment.propagate_configgroup_assignment') + def test_postsave_returns_early_when_assigned_object_type_none(self, mock_job): + asn = ZabbixConfigurationGroupAssignment(zabbixconfigurationgroup=self.cfg, assigned_object_type=None, assigned_object_id=None) + asn.pk = 42 handle_postsave_zabbixconfigurationgroupassignment(sender=ZabbixConfigurationGroupAssignment, instance=asn, created=True) @@ -43,3 +51,11 @@ def test_postdelete_returns_early_when_configgroup_none(self, mock_job): handle_postdelete_zabbixconfigurationgroupassignment(sender=ZabbixConfigurationGroupAssignment, instance=asn) mock_job.delay.assert_not_called() + + @patch('nbxsync.signals.zabbixconfigurationgroupassignment.delete_configgroup_assignment_children') + def test_postdelete_returns_early_when_assigned_object_type_none(self, mock_job): + asn = SimpleNamespace(zabbixconfigurationgroup=self.cfg, assigned_object_type_id=None, assigned_object_id=None) + + handle_postdelete_zabbixconfigurationgroupassignment(sender=ZabbixConfigurationGroupAssignment, instance=asn) + + mock_job.delay.assert_not_called() diff --git a/nbxsync/tests/test_plugin_settings.py b/nbxsync/tests/test_plugin_settings.py index 51c81b8e..fb05d147 100644 --- a/nbxsync/tests/test_plugin_settings.py +++ b/nbxsync/tests/test_plugin_settings.py @@ -45,6 +45,10 @@ def test_inheritance_chain_default(self): settings = PluginSettingsModel() self.assertIn(('role',), settings.inheritance_chain) self.assertIn(('device_type', 'manufacturer'), settings.inheritance_chain) + self.assertIn(('cluster', '_site'), settings.inheritance_chain) + self.assertNotIn(('cluster', 'site'), settings.inheritance_chain) + # Hierarchy must not precede role/platform (upgrade-safe precedence) + self.assertLess(settings.inheritance_chain.index(('role',)), settings.inheritance_chain.index(('site',))) @patch('nbxsync.settings.apps') def test_get_plugin_settings(self, mock_apps): diff --git a/nbxsync/tests/utils/test_cfggroup_resync.py b/nbxsync/tests/utils/test_cfggroup_resync.py index bafe0ce7..76eb6564 100644 --- a/nbxsync/tests/utils/test_cfggroup_resync.py +++ b/nbxsync/tests/utils/test_cfggroup_resync.py @@ -270,3 +270,6 @@ def test_hostinterface_sync_skips_members_without_primary_ip(self, *_mocks): self.assertTrue(ZabbixHostInterface.objects.filter(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=dev_with_ip.pk, parent=hostinterface_parent).exists()) self.assertFalse(ZabbixHostInterface.objects.filter(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=dev_no_ip.pk, parent=hostinterface_parent).exists()) + + def test_delete_children_job_skips_missing_content_type(self): + DeleteConfigGroupAssignmentChildrenJob(configgroup_pk=self.cfg.pk, assigned_object_type_pk=None, assigned_object_id=None).run() diff --git a/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py b/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py index 6ba98b4c..7b7acc23 100644 --- a/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py +++ b/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py @@ -1,10 +1,11 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import Device, DeviceType, Manufacturer +from dcim.models import Device, DeviceType, Manufacturer, Site, SiteGroup from utilities.testing import create_test_device -from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixMacro, ZabbixMacroAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment +from nbxsync.choices import ZabbixProxyTypeChoices +from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixHostInventory, ZabbixMacro, ZabbixMacroAssignment, ZabbixProxy, ZabbixServer, ZabbixServerAssignment, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment from nbxsync.utils.inheritance import get_assigned_zabbixobjects @@ -16,6 +17,8 @@ def setUp(self): self.device_ct = ContentType.objects.get_for_model(Device) self.manufacturer_ct = ContentType.objects.get_for_model(Manufacturer) + self.site_ct = ContentType.objects.get_for_model(Site) + self.sitegroup_ct = ContentType.objects.get_for_model(SiteGroup) self.server = ZabbixServer.objects.create(name='Zabbix1', url='http://localhost', token='abc123', validate_certs=True) @@ -24,6 +27,19 @@ def setUp(self): self.tag = ZabbixTag.objects.create(tag='env', value='prod') self.group = ZabbixHostgroup.objects.create(name='ProdGroup', groupid=201, value='prod', zabbixserver=self.server) + self.site = self.device.site + self.sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + self.site.group = self.sitegroup + self.site.save() + + self.proxy = ZabbixProxy.objects.create( + name='Proxy1', + zabbixserver=self.server, + operating_mode=ZabbixProxyTypeChoices.ACTIVE, + local_address='10.0.0.1', + local_port=10051, + ) + def test_inherited_assignments(self): self.template_assignment = ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=self.manufacturer_ct, assigned_object_id=self.manufacturer.pk) self.macro_assignment = ZabbixMacroAssignment.objects.create(zabbixmacro=self.macro, assigned_object_type=self.manufacturer_ct, assigned_object_id=self.manufacturer.pk, value='mval') @@ -49,3 +65,199 @@ def test_direct_assignments(self): self.assertEqual(len(result['macros']), 1) self.assertEqual(len(result['tags']), 1) self.assertEqual(len(result['hostgroups']), 1) + + def test_inherited_server_assignment_from_site(self): + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.site_ct, + assigned_object_id=self.site.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + self.assertEqual(sa.zabbixserver, self.server) + self.assertEqual(sa.zabbixproxy, self.proxy) + + def test_inherited_server_assignment_from_sitegroup(self): + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.sitegroup_ct, + assigned_object_id=self.sitegroup.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + self.assertEqual(sa.zabbixserver, self.server) + self.assertEqual(sa.zabbixproxy, self.proxy) + + def test_direct_server_assignment_takes_priority_over_inherited(self): + proxy_direct = ZabbixProxy.objects.create( + name='Proxy2', + zabbixserver=self.server, + operating_mode=ZabbixProxyTypeChoices.ACTIVE, + local_address='10.0.0.2', + local_port=10051, + ) + + # Direct assignment on the device + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + zabbixproxy=proxy_direct, + ) + # Inherited assignment on the site + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.site_ct, + assigned_object_id=self.site.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + # Both should be present (direct + inherited) + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + # Direct takes priority + self.assertEqual(sa.zabbixproxy, proxy_direct) + + def test_inherited_hostinventory_from_manufacturer(self): + ZabbixHostInventory.objects.create( + assigned_object_type=self.manufacturer_ct, + assigned_object_id=self.manufacturer.pk, + inventory_mode=1, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertIsNotNone(result['hostinventory']) + self.assertEqual(result['hostinventory'].inventory_mode, 1) + + def test_no_assignments_returns_empty(self): + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + self.assertEqual(result['macros'], []) + self.assertEqual(result['tags'], []) + self.assertEqual(result['hostgroups'], []) + self.assertEqual(result['server_assignments'], []) + self.assertIsNone(result['hostinventory']) + self.assertIsNone(result['configurationgroup']) + + +class TagAssignmentTargetTestCase(TestCase): + """NetBox Tags are assignment targets: an object carrying the tag inherits + the assignment at object level; removing the tag removes the membership.""" + + def setUp(self): + from virtualization.models import VirtualMachine + + from extras.models import Tag as NetBoxTag + + self.server = ZabbixServer.objects.create(name='TagTarget Zabbix', url='http://zabbix.local', token='abc123', validate_certs=True) + self.other_server = ZabbixServer.objects.create(name='Other Zabbix', url='http://zabbix2.local', token='def456', validate_certs=True) + self.hostgroup = ZabbixHostgroup.objects.create(name='Priority/Critical', value='Priority/Critical', zabbixserver=self.server) + self.netbox_tag = NetBoxTag.objects.create(name='critical', slug='critical') + self.tag_ct = ContentType.objects.get_for_model(NetBoxTag) + self.assignment = ZabbixHostgroupAssignment.objects.create(zabbixhostgroup=self.hostgroup, assigned_object_type=self.tag_ct, assigned_object_id=self.netbox_tag.pk) + self.device = create_test_device(name='TaggedDev') + self.vm = VirtualMachine.objects.create(name='TaggedVM') + self.untagged = create_test_device(name='PlainDev') + + def test_tagged_device_inherits_with_tag_label(self): + self.device.tags.add(self.netbox_tag) + result = get_assigned_zabbixobjects(self.device) + groups = result['hostgroups'] + self.assertIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in groups]) + match = next(g for g in groups if g.zabbixhostgroup_id == self.hostgroup.pk) + self.assertEqual(getattr(match, '_inherited_from', None), 'Tag: critical') + + def test_untagged_device_gets_nothing(self): + result = get_assigned_zabbixobjects(self.untagged) + self.assertNotIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in result['hostgroups']]) + + def test_tag_removed_membership_leaves(self): + self.device.tags.add(self.netbox_tag) + self.assertIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in get_assigned_zabbixobjects(self.device)['hostgroups']]) + self.device.tags.remove(self.netbox_tag) + self.assertNotIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in get_assigned_zabbixobjects(self.device)['hostgroups']]) + + def test_tagged_vm_inherits(self): + self.vm.tags.add(self.netbox_tag) + result = get_assigned_zabbixobjects(self.vm) + self.assertIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in result['hostgroups']]) + + def test_server_scoping_filters_tag_targeted_hostgroups(self): + self.device.tags.add(self.netbox_tag) + result = get_assigned_zabbixobjects(self.device, zabbixserver=self.other_server) + self.assertNotIn(self.hostgroup.pk, [g.zabbixhostgroup_id for g in result['hostgroups']]) + + def test_tag_targeted_hostinterface_resolves_with_device_ip(self): + from ipam.models import IPAddress + + from nbxsync.choices import ZabbixHostInterfaceTypeChoices, ZabbixInterfaceTypeChoices, ZabbixInterfaceUseChoices + from nbxsync.models import ZabbixHostInterface + + tag_if = ZabbixHostInterface( + zabbixserver=self.server, + type=ZabbixHostInterfaceTypeChoices.SNMP, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + useip=ZabbixInterfaceUseChoices.IP, + port=161, + dns='', + assigned_object_type=self.tag_ct, + assigned_object_id=self.netbox_tag.pk, + ) + tag_if.full_clean() + tag_if.save() + self.assertIsNone(tag_if.ip) # endpoint cleared — resolved per device + + self.device.tags.add(self.netbox_tag) + result = get_assigned_zabbixobjects(self.device) + matches = [hi for hi in result['hostinterfaces'] if int(hi.type) == ZabbixHostInterfaceTypeChoices.SNMP and int(hi.port) == 161] + self.assertEqual(len(matches), 1) + self.assertTrue(getattr(matches[0], '_is_inherited_copy', False)) + self.assertEqual(getattr(matches[0], '_inherited_from', None), 'Tag: critical') + + self.untagged_ifaces = [hi for hi in get_assigned_zabbixobjects(self.untagged)['hostinterfaces'] if int(hi.port) == 161] + self.assertEqual(self.untagged_ifaces, []) + + +class VirtualMachineDeviceLeakTestCase(TestCase): + """A VM linked to its hosting device (NetBox 4.3+) must not inherit the + host's hardware-tier assignments (manufacturer etc. via 'device'-prefixed + chain paths). A guest is not the hypervisor's hardware. + """ + + def setUp(self): + from virtualization.models import VirtualMachine + + self.server = ZabbixServer.objects.create(name='Leak Server', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Vendor OOB by SNMP', zabbixserver=self.server, templateid=10255) + self.dell = Manufacturer.objects.create(name='Dell', slug='dell-oss') + self.host = create_test_device(name='esx-host-01') + host_type = self.host.device_type + host_type.manufacturer = self.dell + host_type.save() + self.vm = VirtualMachine.objects.create(name='guest-vm-01', device=self.host) + + self.assignment = ZabbixTemplateAssignment.objects.create( + zabbixtemplate=self.template, + assigned_object_type=ContentType.objects.get_for_model(Manufacturer), + assigned_object_id=self.dell.pk, + ) + + def test_device_inherits_manufacturer_template(self): + result = get_assigned_zabbixobjects(self.host) + self.assertIn(self.template.pk, [obj.zabbixtemplate_id for obj in result['templates']]) + + def test_vm_does_not_inherit_manufacturer_template_via_associated_device(self): + result = get_assigned_zabbixobjects(self.vm) + self.assertNotIn(self.template.pk, [obj.zabbixtemplate_id for obj in result['templates']]) diff --git a/nbxsync/tests/utils/test_hostsync.py b/nbxsync/tests/utils/test_hostsync.py index 5f2b193e..9a57527e 100644 --- a/nbxsync/tests/utils/test_hostsync.py +++ b/nbxsync/tests/utils/test_hostsync.py @@ -354,6 +354,27 @@ def __init__(self): result = self.sync.get_template_attributes() self.assertIn({'templateid': 101}, result['templates']) + def test_get_template_attributes_reads_plain_list(self): + """all_objects collections are always plain lists (no _get_all_objects dict/list shim).""" + + class DummyZabbixTemplate: + def __init__(self, templateid): + self.templateid = templateid + self.interface_requirements = [HostInterfaceRequirementChoices.NONE] + + class DummyAssignedTemplate: + def __init__(self, templateid): + self.zabbixtemplate = DummyZabbixTemplate(templateid) + + assigned = [DummyAssignedTemplate(201), DummyAssignedTemplate(202), DummyAssignedTemplate(203)] + self.assertIsInstance(assigned, list) + self.sync.context['all_objects']['templates'] = assigned + + result = self.sync.get_template_attributes() + self.assertEqual(len(result['templates']), 3) + for templateid in (201, 202, 203): + self.assertIn({'templateid': templateid}, result['templates']) + def test_get_template_attributes_with_any_but_no_interfaces(self): class DummyZabbixTemplate: def __init__(self): @@ -467,7 +488,7 @@ class DummyHostInventory: def __init__(self): self.inventory_mode = 1 - def render_all_fields(self): + def render_all_fields(self, object=None): return { 'serialnumber': ('ABC123', True), 'location': ('', True), # Empty, should be skipped diff --git a/nbxsync/tests/utils/test_inheritance_recursive.py b/nbxsync/tests/utils/test_inheritance_recursive.py new file mode 100644 index 00000000..06a19756 --- /dev/null +++ b/nbxsync/tests/utils/test_inheritance_recursive.py @@ -0,0 +1,180 @@ +from unittest.mock import Mock + +from django.test import TestCase + +from dcim.models import DeviceRole, Region, Site, SiteGroup +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixProxy, ZabbixServer, ZabbixServerAssignment, ZabbixTemplate, ZabbixTemplateAssignment +from nbxsync.utils.inheritance import _walk_ancestors, resolve_inherited_zabbix_assignments + + +class WalkAncestorsTestCase(TestCase): + def test_walk_sitegroup_ancestors(self): + root = SiteGroup.objects.create(name='HU', slug='hu') + mid = SiteGroup.objects.create(name='HU-DEB', slug='hu-deb', parent=root) + leaf = SiteGroup.objects.create(name='HU-DEB-NAG', slug='hu-deb-nag', parent=mid) + ancestors = list(_walk_ancestors(leaf)) + self.assertEqual(ancestors, [leaf, mid, root]) + + def test_walk_with_none(self): + self.assertEqual(list(_walk_ancestors(None)), []) + + def test_cycle_detected(self): + first = Mock(name='first') + second = Mock(name='second') + first.parent = second + second.parent = first + + self.assertEqual(list(_walk_ancestors(first)), [first, second]) + + def test_walk_region_ancestors(self): + root = Region.objects.create(name='Europe', slug='europe') + child = Region.objects.create(name='Hungary', slug='hu', parent=root) + leaf = Region.objects.create(name='Budapest', slug='bp', parent=child) + ancestors = list(_walk_ancestors(leaf)) + self.assertEqual(ancestors, [leaf, child, root]) + + def test_walk_devicerole_ancestors(self): + root = DeviceRole.objects.create(name='Network', slug='network') + child = DeviceRole.objects.create(name='Switch', slug='switch', parent=root) + leaf = DeviceRole.objects.create(name='Core Switch', slug='core-switch', parent=child) + ancestors = list(_walk_ancestors(leaf)) + self.assertEqual(ancestors, [leaf, child, root]) + + +class RecursiveInheritanceTestCase(TestCase): + def setUp(self): + self.server = ZabbixServer.objects.create(name='Zabbix', url='http://zabbix.local', token='t') + self.root_sg = SiteGroup.objects.create(name='HU', slug='hu') + self.mid_sg = SiteGroup.objects.create(name='HU-DEB', slug='hu-deb', parent=self.root_sg) + self.leaf_sg = SiteGroup.objects.create(name='HU-DEB-NAG', slug='hu-deb-nag', parent=self.mid_sg) + self.site = Site.objects.create(name='HU-DEB-NAG-B', slug='hu-deb-nag-b', group=self.leaf_sg) + self.device = create_test_device(name='Dev-at-HU', site=self.site) + from django.contrib.contenttypes.models import ContentType + + self.sg_ct = ContentType.objects.get_for_model(SiteGroup) + self.region_ct = ContentType.objects.get_for_model(Region) + self.role_ct = ContentType.objects.get_for_model(DeviceRole) + + def test_root_sitegroup_assignment_resolves_for_deep_hierarchy(self): + """Assignment on HU (root) is found for a device at HU-DEB-NAG-B (3 levels deep).""" + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.sg_ct, assigned_object_id=self.root_sg.pk) + result = resolve_inherited_zabbix_assignments(self.device) + self.assertIn(self.server.pk, result['server_assignments']) + assignment = result['server_assignments'][self.server.pk] + self.assertEqual(assignment._inherited_from, 'Site Group: HU') + + def test_nearest_ancestor_wins(self): + """The nearest assignment wins when ancestors target the same server.""" + root_proxy = ZabbixProxy.objects.create(name='Root Proxy', zabbixserver=self.server, local_address='192.0.2.10', operating_mode=0) + leaf_proxy = ZabbixProxy.objects.create(name='Leaf Proxy', zabbixserver=self.server, local_address='192.0.2.11', operating_mode=0) + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.sg_ct, + assigned_object_id=self.root_sg.pk, + zabbixproxy=root_proxy, + ) + leaf_assignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.sg_ct, + assigned_object_id=self.leaf_sg.pk, + zabbixproxy=leaf_proxy, + ) + + result = resolve_inherited_zabbix_assignments(self.device) + + self.assertEqual(result['server_assignments'][self.server.pk], leaf_assignment) + self.assertEqual(result['server_assignments'][self.server.pk].zabbixproxy, leaf_proxy) + + def test_region_parent_assignment_resolves_for_deep_hierarchy(self): + """Assignment on a root Region propagates down a multi-level Region tree.""" + root_region = Region.objects.create(name='Europe', slug='europe') + mid_region = Region.objects.create(name='Hungary', slug='hu', parent=root_region) + leaf_region = Region.objects.create(name='Budapest', slug='bp', parent=mid_region) + region_site = Site.objects.create(name='BP-DC', slug='bp-dc', region=leaf_region) + device = create_test_device(name='Dev-at-BP', site=region_site) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.region_ct, assigned_object_id=root_region.pk) + result = resolve_inherited_zabbix_assignments(device) + self.assertIn(self.server.pk, result['server_assignments']) + + def test_devicerole_parent_assignment_resolves_for_deep_hierarchy(self): + """Assignment on a parent DeviceRole propagates down a multi-level Role tree.""" + root_role = DeviceRole.objects.create(name='Network', slug='network') + mid_role = DeviceRole.objects.create(name='Switch', slug='switch', parent=root_role) + leaf_role = DeviceRole.objects.create(name='Core Switch', slug='core-switch', parent=mid_role) + device = create_test_device(name='Dev-Core-Switch') + device.role = leaf_role + device.save(update_fields=['role']) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.role_ct, assigned_object_id=root_role.pk) + result = resolve_inherited_zabbix_assignments(device) + self.assertIn(self.server.pk, result['server_assignments']) + + + + +class ResolveInheritedAssignmentsBatchedTestCase(TestCase): + """Contract tests for the batched query refactor of resolve_inherited_zabbix_assignments.""" + + def setUp(self): + self.server = ZabbixServer.objects.create(name='Zabbix', url='http://zabbix.local', token='t') + + def _sitegroup_chain(self, depth): + """Build a SiteGroup chain of the given depth (root .. leaf) and a device under the leaf.""" + parent = None + groups = [] + for i in range(depth): + sg = SiteGroup.objects.create(name=f'D{depth}-G{i}', slug=f'd{depth}-g{i}', parent=parent) + groups.append(sg) + parent = sg + leaf = groups[-1] + site = Site.objects.create(name=f'Site-{depth}', slug=f'site-{depth}', group=leaf) + device = create_test_device(name=f'Dev-{depth}', site=site) + return groups, device + + def test_query_count_is_bounded_independent_of_depth(self): + """Query count must stay ~constant as SiteGroup depth grows (no N+1).""" + from django.contrib.contenttypes.models import ContentType + from django.test.utils import CaptureQueriesContext + from django.db import connection + + # Build two hierarchies of different depth and measure the resolver's query + # count for each. The batched refactor issues ~constant queries regardless + # of ancestor count; the old per-ancestor loop scaled as ~7×N. + sg_ct = ContentType.objects.get_for_model(SiteGroup) + groups_2, device_2 = self._sitegroup_chain(2) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=sg_ct, assigned_object_id=groups_2[0].pk) + groups_5, device_5 = self._sitegroup_chain(5) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=sg_ct, assigned_object_id=groups_5[0].pk) + + with CaptureQueriesContext(connection) as ctx_2: + resolve_inherited_zabbix_assignments(device_2) + with CaptureQueriesContext(connection) as ctx_5: + resolve_inherited_zabbix_assignments(device_5) + + # The deeper hierarchy has 3 more ancestors but must not multiply queries. + self.assertLess(len(ctx_5.captured_queries), 60, f'Query count exploded at depth 5: {len(ctx_5.captured_queries)}') + self.assertLessEqual(len(ctx_5.captured_queries), len(ctx_2.captured_queries) + 20, + f'Query count grew disproportionately with depth: {len(ctx_2.captured_queries)} -> {len(ctx_5.captured_queries)}') + + def test_leaf_assignment_wins_over_root(self): + """A template assigned to both the root and the leaf SiteGroup resolves to the leaf one (first-seen-wins, leaf-before-ancestor).""" + from django.contrib.contenttypes.models import ContentType + + root = SiteGroup.objects.create(name='Root', slug='root') + leaf = SiteGroup.objects.create(name='Leaf', slug='leaf', parent=root) + site = Site.objects.create(name='LeafSite', slug='leaf-site', group=leaf) + device = create_test_device(name='DevAtLeaf', site=site) + + root_template = ZabbixTemplate.objects.create(name='RootTpl', zabbixserver=self.server, templateid=1) + leaf_template = ZabbixTemplate.objects.create(name='LeafTpl', zabbixserver=self.server, templateid=2) + sg_ct = ContentType.objects.get_for_model(SiteGroup) + ZabbixTemplateAssignment.objects.create(zabbixtemplate=root_template, assigned_object_type=sg_ct, assigned_object_id=root.pk) + ZabbixTemplateAssignment.objects.create(zabbixtemplate=leaf_template, assigned_object_type=sg_ct, assigned_object_id=leaf.pk) + + result = resolve_inherited_zabbix_assignments(device) + + self.assertEqual(set(result['templates'].keys()), {leaf_template.pk, root_template.pk}) + # Both resolve (different template ids), and the leaf's label is attached to its row. + leaf_row = result['templates'][leaf_template.pk] + self.assertIn('Leaf', leaf_row._inherited_from) diff --git a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py index 6af2df27..1c2dd6fc 100644 --- a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py +++ b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py @@ -1,16 +1,15 @@ -from unittest.mock import Mock, patch from types import SimpleNamespace -from collections import defaultdict +from unittest.mock import Mock, patch -from django.db.models.query import QuerySet from django.contrib.contenttypes.models import ContentType +from django.db.models.query import QuerySet from django.test import SimpleTestCase, TestCase from dcim.models import DeviceType, Manufacturer from utilities.testing import create_test_device -from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixMacro, ZabbixMacroAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment, ZabbixConfigurationGroup, ZabbixConfigurationGroupAssignment -from nbxsync.utils.inheritance import get_assigned_zabbixobjects, resolve_inherited_zabbix_assignments, _merge_direct_and_inherited, _INHERITANCE_MODELS +from nbxsync.models import ZabbixConfigurationGroup, ZabbixConfigurationGroupAssignment, ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixMacro, ZabbixMacroAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment +from nbxsync.utils.inheritance import _merge_direct_and_inherited, resolve_inherited_zabbix_assignments class ResolveInheritedAssignmentsTestCase(TestCase): @@ -134,71 +133,29 @@ def test_no_direct_means_all_inherited_flow_through(self): self.assertEqual(len(result), 1) -class ResolveInheritedFirstWriteWinsTests(SimpleTestCase): - """ - Regression: the `continue` inside `resolve_inherited_zabbix_assignments` - must reject an assignment whose dedup id was already seen from an - earlier path. Without it, a later path silently overwrites the - `_inherited_from` label of the earlier one. - """ +class ResolveInheritedFirstWriteWinsTests(TestCase): + """First inheritance path wins when the same template is assigned on two parents.""" + + def setUp(self): + self.device = create_test_device(name='FirstWriteWins') + self.zabbixserver = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Shared', zabbixserver=self.zabbixserver, templateid=4242) + role_ct = ContentType.objects.get_for_model(self.device.role) + dtype_ct = ContentType.objects.get_for_model(self.device.device_type) + ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=role_ct, assigned_object_id=self.device.role.pk) + ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=dtype_ct, assigned_object_id=self.device.device_type.pk) @patch('nbxsync.utils.inheritance.get_plugin_settings') - @patch('nbxsync.utils.inheritance._index_assignments') - @patch('nbxsync.utils.inheritance._resolve_parents') - def test_second_path_with_duplicate_id_is_skipped(self, mock_resolve_parents, mock_index_assignments, mock_settings): - # Two resolved paths, both pointing at the same (ct_id, pk) bucket - # keyed by (99, 1) in the index. Labels differ so we can tell which - # one won. + def test_second_path_with_duplicate_id_is_skipped(self, mock_settings): mock_settings.return_value.inheritance_chain = [ - ('device_type',), # PATH_LABELS -> 'Device Type' - ('device_type', 'manufacturer'), # PATH_LABELS -> 'Manufacturer' + ('role',), + ('device_type',), ] - parent_a = SimpleNamespace(pk=1) - parent_b = SimpleNamespace(pk=2) - mock_resolve_parents.return_value = ( - [ - (('device_type',), parent_a, 99), - (('device_type', 'manufacturer'), parent_b, 99), - ], - {99: {1, 2}}, - ) - - # For the FIRST assignment model (templates), return an entry at - # BOTH parent keys with the SAME dedup id. This is the collision - # that the `continue` protects against. For the remaining four - # models, return empty dicts. - dedup_attr = _INHERITANCE_MODELS[0][2] # 'zabbixtemplate_id' - shared_dedup_value = 4242 - - assignment_from_path_a = SimpleNamespace(**{dedup_attr: shared_dedup_value}) - assignment_from_path_b = SimpleNamespace(**{dedup_attr: shared_dedup_value}) - - templates_index = defaultdict( - list, - { - (99, 1): [assignment_from_path_a], # first path resolves here - (99, 2): [assignment_from_path_b], # second path resolves here - }, - ) - empty_indexes = [defaultdict(list) for _ in _INHERITANCE_MODELS[1:]] - mock_index_assignments.return_value = [templates_index, *empty_indexes] - - result = resolve_inherited_zabbix_assignments(SimpleNamespace()) - - # Exactly one entry — the second was rejected by the `continue`. - self.assertEqual(len(result['templates']), 1) + result = resolve_inherited_zabbix_assignments(self.device) - # The surviving entry is the one from path A, so its - # _inherited_from label is 'Device Type', NOT 'Manufacturer'. - # If the `continue` were removed, the loop would fall through, - # set assignment_from_path_b._inherited_from = 'Manufacturer', - # and overwrite results[0][shared_dedup_value] with it. + self.assertEqual(len(result['templates']), 1) surviving = next(iter(result['templates'].values())) - self.assertIs(surviving, assignment_from_path_a) - self.assertEqual(surviving._inherited_from, 'Device Type') - - # And the path-B assignment must NOT have been mutated — the - # `continue` fires *before* the `assignment._inherited_from = label` - # line, so it never gets the attribute. - self.assertFalse(hasattr(assignment_from_path_b, '_inherited_from')) + self.assertEqual(surviving.zabbixtemplate_id, self.template.pk) + self.assertTrue(str(surviving._inherited_from).startswith('Role'), surviving._inherited_from) + self.assertNotIn('Device Type', str(surviving._inherited_from)) diff --git a/nbxsync/tests/utils/test_syncbase.py b/nbxsync/tests/utils/test_syncbase.py index ec121e6d..f0187bb0 100644 --- a/nbxsync/tests/utils/test_syncbase.py +++ b/nbxsync/tests/utils/test_syncbase.py @@ -272,3 +272,16 @@ def test_resolve_zabbixserver_with_no_fallback_path(self): obj = self.NoZabbixAttrs() result = resolve_zabbixserver(obj, fallback_path=None) self.assertIsNone(result) + + def test_should_persist_skips_save_for_inherited_copy(self): + """When _is_inherited_copy is True, sync() must not call save() or update_sync_info().""" + self.obj._is_inherited_copy = True + # sot is ZABBIX in setUp, so sync() will find the object and call sync_from_zabbix + save() + self.sync.sync() + self.assertFalse(self.obj._saved) + self.assertEqual(self.obj._updated, {}) + + def test_should_persist_saves_when_not_inherited_copy(self): + """When _is_inherited_copy is absent/False, sync() calls save() as usual.""" + self.sync.sync() + self.assertTrue(self.obj._saved) diff --git a/nbxsync/tests/views/test_zabbixconfigurationgroupassignment.py b/nbxsync/tests/views/test_zabbixconfigurationgroupassignment.py index e63ef918..13233bd7 100644 --- a/nbxsync/tests/views/test_zabbixconfigurationgroupassignment.py +++ b/nbxsync/tests/views/test_zabbixconfigurationgroupassignment.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -117,6 +119,11 @@ def test_hostgroupassignment_form_invalid_content_type_triggers_prefill_exceptio # Verify our except path executed and logged self.assertTrue(any('Prefill error' in msg for msg in cm.output)) + def test_form_exposes_role_manufacturer_and_tag(self): + form = ZabbixConfigurationGroupAssignmentForm() + for field in ('role', 'manufacturer', 'platform', 'devicetype', 'cluster', 'clustertype', 'tag'): + self.assertIn(field, form.fields) + def test_clean_raises_if_multiple_assignments(self): form = ZabbixConfigurationGroupAssignmentForm( data={ @@ -129,10 +136,49 @@ def test_clean_raises_if_multiple_assignments(self): self.assertIn('virtualmachine', form.errors) self.assertIn('A Zabbix Configuration Group can only be assigned to a single object.', form.errors['virtualmachine'][0]) - def test_clean_sets_assigned_object_none_if_unassigned(self): + def test_clean_requires_assigned_object(self): form = ZabbixConfigurationGroupAssignmentForm(data={'zabbixconfigurationgroup': str(self.zabbix_configurationgroups[0].id)}) - form.is_valid() - - # Should not raise and assigned_object should be None + self.assertFalse(form.is_valid()) + self.assertIn('A Zabbix Configuration Group must be assigned to an object.', str(form.errors)) self.assertIsNone(form.instance.assigned_object) + + def test_clean_assigns_device_role(self): + role = self.devices[0].role + form = ZabbixConfigurationGroupAssignmentForm( + data={ + 'zabbixconfigurationgroup': str(self.zabbix_configurationgroups[0].id), + 'role': role.pk, + } + ) + + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.assigned_object, role) + + def test_clean_assigns_manufacturer(self): + manufacturer = self.devices[0].device_type.manufacturer + form = ZabbixConfigurationGroupAssignmentForm( + data={ + 'zabbixconfigurationgroup': str(self.zabbix_configurationgroups[0].id), + 'manufacturer': manufacturer.pk, + } + ) + + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.assigned_object, manufacturer) + + @patch('nbxsync.signals.zabbixconfigurationgroupassignment.delete_configgroup_assignment_children') + def test_delete_null_assignment_succeeds(self, _mock_job): + self.user.is_superuser = True + self.user.save() + + obj = ZabbixConfigurationGroupAssignment.objects.create( + zabbixconfigurationgroup=self.zabbix_configurationgroups[0], + assigned_object_type=None, + assigned_object_id=None, + ) + url = reverse('plugins:nbxsync:zabbixconfigurationgroupassignment_delete', args=[obj.pk]) + response = self.client.post(url, data={'confirm': True}) + + self.assertIn(response.status_code, (200, 302)) + self.assertFalse(ZabbixConfigurationGroupAssignment.objects.filter(pk=obj.pk).exists()) diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index d8e9e27e..974d612a 100644 --- a/nbxsync/utils/inheritance.py +++ b/nbxsync/utils/inheritance.py @@ -1,49 +1,18 @@ -from collections import OrderedDict, defaultdict -from typing import Any, Callable, Iterable +import copy as _copy +from collections import OrderedDict from django.contrib.contenttypes.models import ContentType -from django.db.models import Model, Q, QuerySet +from django.db.models import Q, QuerySet from django.db.models.manager import BaseManager +from dcim.models import DeviceRole, Region, SiteGroup +from extras.models import Tag +from virtualization.models import VirtualMachine + from nbxsync.constants import PATH_LABELS -from nbxsync.models import ZabbixConfigurationGroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixHostgroupAssignment, ZabbixMacroAssignment, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment +from nbxsync.models import ZabbixConfigurationGroupAssignment, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixMacroAssignment, ZabbixServerAssignment, ZabbixTagAssignment, ZabbixTemplateAssignment from nbxsync.settings import get_plugin_settings -from nbxsync.tables import ZabbixHostgroupAssignmentObjectViewTable, ZabbixMacroAssignmentObjectViewTable, ZabbixTagAssignmentObjectViewTable, ZabbixTemplateAssignmentObjectViewTable - - -def _template_server_filter(server): - return Q(zabbixtemplate__zabbixserver_id=server.id) - - -def _hostgroup_server_filter(server): - return Q(zabbixhostgroup__zabbixserver_id=server.id) - - -def _macro_server_filter(server): - """ - Macros are attached (via generic FK) to either a ZabbixServer or a - ZabbixTemplate. Include: - - macros whose assigned_object IS this ZabbixServer, or - - macros whose assigned_object is a ZabbixTemplate belonging to this - ZabbixServer. - """ - server_ct = ContentType.objects.get_for_model(server.__class__) - template_ct = ContentType.objects.get_for_model(ZabbixTemplate) - templates_on_server = ZabbixTemplate.objects.filter(zabbixserver_id=server.id).values('id') - - return Q(zabbixmacro__assigned_object_type=server_ct, zabbixmacro__assigned_object_id=server.id) | Q(zabbixmacro__assigned_object_type=template_ct, zabbixmacro__assigned_object_id__in=templates_on_server) - - -# Each row: (model, select_related field, dedup key attr, server-filter builder). -# server-filter builder is None for models that have no ZabbixServer relation. -_INHERITANCE_MODELS = ( - (ZabbixTemplateAssignment, 'zabbixtemplate', 'zabbixtemplate_id', _template_server_filter), - (ZabbixMacroAssignment, 'zabbixmacro', 'zabbixmacro_id', _macro_server_filter), - (ZabbixTagAssignment, 'zabbixtag', 'id', None), - (ZabbixHostgroupAssignment, 'zabbixhostgroup', 'zabbixhostgroup_id', _hostgroup_server_filter), - (ZabbixConfigurationGroupAssignment, 'zabbixconfigurationgroup', 'zabbixconfigurationgroup_id', None), -) -_RESULT_KEYS = ('templates', 'macros', 'tags', 'hostgroups', 'configurationgroups') +from nbxsync.tables import ZabbixHostgroupAssignmentObjectViewTable, ZabbixMacroAssignmentObjectViewTable, ZabbixServerAssignmentObjectViewTable, ZabbixTagAssignmentObjectViewTable, ZabbixTemplateAssignmentObjectViewTable def get_zabbixassignments_for_request(instance, request): @@ -55,213 +24,390 @@ def get_zabbixassignments_for_request(instance, request): content_type = ContentType.objects.get_for_model(instance) def table_or_none(data, table_cls, attach_instance=False): - if not data: - return None - - table = table_cls(data) - table.configure(request) - - if attach_instance: - table.instance = instance - - return table + if data: + table = table_cls(data) + table.configure(request) + if attach_instance: + table.instance = instance + return table + return None return { + 'zabbixserver_assignments_table': table_or_none(assignments.get('server_assignments'), ZabbixServerAssignmentObjectViewTable), 'zabbix_template_table': table_or_none(assignments['templates'], ZabbixTemplateAssignmentObjectViewTable), '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), + 'hostinventory_assignment': assignments.get('hostinventory'), + 'configurationgroup_assignment': assignments.get('configurationgroup'), 'object': instance, 'content_type': content_type, } def _merge_direct_and_inherited(direct_list, inherited_map, key): - direct_ids = set() - for direct_obj in direct_list: - direct_id = getattr(direct_obj, key) - direct_ids.add(direct_id) - - extras = [] - for inherited_obj in inherited_map.values(): - inherited_id = getattr(inherited_obj, key) - if inherited_id in direct_ids: - continue - extras.append(inherited_obj) - - return direct_list + extras + """Direct assignments win; inherited rows with the same key are skipped.""" + inherited_map = inherited_map or {} + direct_ids = {getattr(obj, key) for obj in direct_list} + inherited_filtered = [obj for obj in inherited_map.values() if getattr(obj, key) not in direct_ids] + return list(direct_list) + inherited_filtered def get_assigned_zabbixobjects(instance, zabbixserver=None): """ - Return raw Zabbix assignment lists (direct + inherited) without any table - formatting. - - If ``zabbixserver`` is given, results are scoped to that server: - - Templates / Macros / Hostgroups / HostInterfaces are filtered to the - server they belong to (see ``_INHERITANCE_MODELS`` for the exact - traversal per model). - - Tags, ConfigurationGroups and HostInventory have no server relation - and are returned regardless. + Return raw Zabbix assignment lists (direct + inherited) without any table formatting. + + When *zabbixserver* is given, server-scoped objects (templates, hostgroups, + host interfaces) are filtered to that server only. Macros, tags, inventory + and configuration-group assignments are server-agnostic and returned + unfiltered. """ content_type = ContentType.objects.get_for_model(instance) - base = Q(assigned_object_type=content_type, assigned_object_id=instance.id) - - def direct(model, select_field, server_filter): - qs = model.objects.filter(base).select_related(select_field) - if zabbixserver is not None and server_filter is not None: - qs = qs.filter(server_filter(zabbixserver)) - - return qs - direct_templates = list(direct(ZabbixTemplateAssignment, 'zabbixtemplate', _template_server_filter)) - direct_macros = list(direct(ZabbixMacroAssignment, 'zabbixmacro', _macro_server_filter)) - direct_tags = list(direct(ZabbixTagAssignment, 'zabbixtag', None)) - direct_hostgroups = list(direct(ZabbixHostgroupAssignment, 'zabbixhostgroup', _hostgroup_server_filter)) - - # HostInterfaces have their own zabbixserver FK (not routed through a nested assignment), so filter it directly. - hostinterface_qs = ZabbixHostInterface.objects.filter(base) - if zabbixserver is not None: - hostinterface_qs = hostinterface_qs.filter(zabbixserver_id=zabbixserver.id) - - hostinterfaces = list(hostinterface_qs) - - hostinventory = ZabbixHostInventory.objects.filter(base).first() - configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(base).first() - - inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver=zabbixserver) + # Direct assignments — server-scoped querysets are filtered conditionally + templates_qs = ZabbixTemplateAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtemplate') + if zabbixserver: + templates_qs = templates_qs.filter(zabbixtemplate__zabbixserver=zabbixserver) + direct_templates = list(templates_qs) + + direct_macros = list(ZabbixMacroAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixmacro')) + direct_tags = list(ZabbixTagAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtag')) + + hostgroups_qs = ZabbixHostgroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixhostgroup') + if zabbixserver: + hostgroups_qs = hostgroups_qs.filter(zabbixhostgroup__zabbixserver=zabbixserver) + direct_hostgroups = list(hostgroups_qs) + + hostinterfaces_qs = ZabbixHostInterface.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id) + if zabbixserver: + hostinterfaces_qs = hostinterfaces_qs.filter(zabbixserver=zabbixserver) + direct_hostinterfaces = list(hostinterfaces_qs) + + direct_server_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixproxy', 'zabbixproxygroup') + if zabbixserver: + direct_server_assignments = direct_server_assignments.filter(zabbixserver=zabbixserver) + direct_server_assignments = list(direct_server_assignments) + + hostinventory = ZabbixHostInventory.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() + direct_configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() + + inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver) + + if not hostinventory: + hostinventory = inherited.get('hostinventory') + + configurationgroup = direct_configurationgroup or next(iter(inherited.get('configurationgroups', {}).values()), None) + + # Merge direct + inherited (direct takes priority) + # ZabbixHostInterfaces assigned to SiteGroup/Role/Site are resolved + # naturally by the inheritance chain. If the interface lacks an IP, + # HostInterfaceSync.get_create_params() falls back to the device's + # primary IP automatically. + hostinterfaces = _merge_direct_and_inherited(direct_hostinterfaces, inherited.get('hostinterfaces', {}), 'id') + # Expand ConfigGroup-defined interfaces for this specific instance. + # When a ConfigGroup is assigned at Site/Platform level, the signal-based + # propagation cannot clone per-device interfaces (Site has no primary_ip). + # Resolve them here so HostSync and HostInterfaceSync get device-specific interfaces. + if configurationgroup: + cg_ct = ContentType.objects.get_for_model(configurationgroup.zabbixconfigurationgroup) + cg_interfaces = ZabbixHostInterface.objects.filter( + assigned_object_type=cg_ct, + assigned_object_id=configurationgroup.zabbixconfigurationgroup_id, + ) + existing_types = {hi.type for hi in hostinterfaces} + primary_ip = getattr(instance, 'primary_ip4', None) or getattr(instance, 'primary_ip6', None) + for cg_iface in cg_interfaces: + if cg_iface.type not in existing_types: + # Clone the interface with the device's primary IP + child = _copy.copy(cg_iface) + child.pk = None + child._is_inherited_copy = True + child.assigned_object_type = content_type + child.assigned_object_id = instance.id + child.ip = primary_ip if primary_ip else None + hostinterfaces.append(child) + + # Merge direct + inherited (direct takes priority) + merged_templates = _merge_direct_and_inherited(direct_templates, inherited['templates'], 'zabbixtemplate_id') + merged_hostgroups = _merge_direct_and_inherited(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id') + merged_tags = _merge_direct_and_inherited(direct_tags, inherited['tags'], 'id') return { - 'templates': _merge_direct_and_inherited(direct_templates, inherited['templates'], 'zabbixtemplate_id'), + 'templates': merged_templates, 'macros': _merge_direct_and_inherited(direct_macros, inherited['macros'], 'zabbixmacro_id'), - 'tags': _merge_direct_and_inherited(direct_tags, inherited['tags'], 'id'), - 'hostgroups': _merge_direct_and_inherited(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id'), + 'tags': merged_tags, + 'hostgroups': merged_hostgroups, 'hostinterfaces': hostinterfaces, 'hostinventory': hostinventory, 'configurationgroup': configurationgroup, + 'server_assignments': _merge_direct_and_inherited(direct_server_assignments, inherited.get('server_assignments', {}), 'zabbixserver_id'), } -def _walk_path(obj, path): - """ - Follow a dotted attribute path on a model instance. +def _walk_ancestors(obj, parent_attr='parent'): + """Yield obj, then each ancestor via parent_attr, until None. - Django caches FK lookups on the instance (in ``_state.fields_cache``), - so sibling paths that share a prefix (e.g. ``('device',)`` and - ``('device', 'role')``) do not re-issue the FK query. + Includes obj itself so callers can check assignments on the + starting object AND all ancestors. Cycle-safe via a seen-set. """ - - for attr in path: - obj = getattr(obj, attr, None) - if obj is None: - return None - - if isinstance(obj, (BaseManager, QuerySet)): - obj = obj.first() - if obj is None: + if obj is None: + return + seen = set() + cur = obj + while cur is not None and cur not in seen: + yield cur + seen.add(cur) + cur = getattr(cur, parent_attr, None) + + +def _inheritance_source(path, source_obj): + label = PATH_LABELS.get(path, '.'.join(path)) + if isinstance(source_obj, (SiteGroup, Region, DeviceRole)): + return f'{label}: {source_obj}' + return label + + +def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): # noqa: C901 + resolved_templates = OrderedDict() + resolved_server_assignments = OrderedDict() + resolved_hostinterfaces = OrderedDict() + resolved_hostinventory = None + resolved_macros = OrderedDict() + resolved_tags = OrderedDict() + resolved_hostgroups = OrderedDict() + resolved_configurationgroups = OrderedDict() + seen_template_ids = set() + seen_macro_ids = set() + seen_tag_ids = set() + seen_hostgroup_ids = set() + seen_configurationgroup_ids = set() + + def resolve_path(obj, path): + cur = obj + seen = set() + for attr in path: + cur = getattr(cur, attr, None) + if cur is None: return None + # If the attribute is a manager or queryset, take the first related object + if isinstance(cur, (BaseManager, QuerySet)): + cur = cur.first() + # If it's something that still isn't a model instance after collapsing, bail + if cur is None: + return None + if cur in seen: + return None # cycle detected + seen.add(cur) + return cur - return obj if isinstance(obj, Model) else None - + pluginsettings = get_plugin_settings() -def _resolve_parents(assigned_object, paths): - """ - Walk every path once, returning ``(path, related_obj, ct_id)`` triples - (with ``None`` for paths that don't resolve) and a ``{ct_id: {pk, ...}}`` - map for batching the assignment queries. - """ - resolved = [] - pks_by_ct = defaultdict(set) - ct_id_by_model = {} - - for path in paths: - related_obj = _walk_path(assigned_object, path) - if related_obj is None: - resolved.append((path, None, None)) + # --- Collect phase --- + # Walk every inheritance-chain path and gather (content_type, object_pk, label) + # triples in leaf-first order. No queries are issued here. The seen_objects + # dedup preserves the original "first path an object is seen on wins" semantics. + triples = [] + seen_objects = set() + + # Tag-targeted assignments resolve at object level: an object inherits every + # assignment pointed at a NetBox Tag it carries. Collected before the + # hierarchy chain so an attribute-level source beats a distant hierarchy + # source on first-seen dedup. Guarded: only taggable models enter, and the + # tagging manager is never allowed to abort resolution. + if hasattr(assigned_object, 'tags'): + tag_ct = ContentType.objects.get_for_model(Tag, for_concrete_model=False) + try: + object_tags = list(assigned_object.tags.all()) + except Exception: + object_tags = [] + for tag in object_tags: + object_key = (tag_ct.pk, tag.pk) + if object_key in seen_objects: + continue + seen_objects.add(object_key) + triples.append((tag_ct, tag.pk, f'Tag: {tag.name}')) + + for path in pluginsettings.inheritance_chain: + # 'device'-prefixed paths describe the *associated physical device* + # (the VDC's parent, or — since NetBox 4.3 — a VM's hosting device). + # For a VirtualMachine that association is the hypervisor/sidecar, so + # walking these paths would leak host properties (manufacturer, role, + # hardware templates) onto the guest. VDCs keep the paths: a VDC is + # part of its parent device by definition. + if path and path[0] == 'device' and isinstance(assigned_object, VirtualMachine): continue - model_cls = type(related_obj) - ct_id = ct_id_by_model.get(model_cls) - if ct_id is None: - ct_id = ContentType.objects.get_for_model(model_cls).id - ct_id_by_model[model_cls] = ct_id - - resolved.append((path, related_obj, ct_id)) - pks_by_ct[ct_id].add(related_obj.pk) - - return resolved, pks_by_ct - - -def _index_assignments(pks_by_ct, zabbixserver): - """ - Fetch all inherited assignments in exactly ``len(_INHERITANCE_MODELS)`` - queries and index each result set by ``(ct_id, object_id)``. - """ - if not pks_by_ct: - return [defaultdict(list) for _ in _INHERITANCE_MODELS] - - parent_filter = Q() - for ct_id, pks in pks_by_ct.items(): - parent_filter |= Q(assigned_object_type_id=ct_id, assigned_object_id__in=pks) - - indexes = [] - for model, select_field, _, server_filter in _INHERITANCE_MODELS: - qs = model.objects.filter(parent_filter).select_related(select_field) - if zabbixserver is not None and server_filter is not None: - qs = qs.filter(server_filter(zabbixserver)) + related_obj = resolve_path(assigned_object, path) - idx = defaultdict(list) - for row in qs: - idx[(row.assigned_object_type_id, row.assigned_object_id)].append(row) + if not related_obj: + continue - indexes.append(idx) + if isinstance(related_obj, (SiteGroup, Region, DeviceRole)): + objects_to_check = _walk_ancestors(related_obj) + else: + objects_to_check = (related_obj,) + + for ancestor_obj in objects_to_check: + ct = ContentType.objects.get_for_model(ancestor_obj) + object_key = (ct.pk, ancestor_obj.pk) + if object_key in seen_objects: + continue + seen_objects.add(object_key) + label = _inheritance_source(path, ancestor_obj) + triples.append((ct, ancestor_obj.pk, label)) + + resolved_hostinventory = _resolve_inherited_assignments_batched( + triples, + zabbixserver, + resolved_templates, + resolved_server_assignments, + resolved_hostinterfaces, + resolved_macros, + resolved_tags, + resolved_hostgroups, + resolved_configurationgroups, + seen_template_ids, + seen_macro_ids, + seen_tag_ids, + seen_hostgroup_ids, + seen_configurationgroup_ids, + ) - return indexes + return { + 'server_assignments': resolved_server_assignments, + 'hostinterfaces': resolved_hostinterfaces, + 'hostinventory': resolved_hostinventory, + 'templates': resolved_templates, + 'macros': resolved_macros, + 'tags': resolved_tags, + 'hostgroups': resolved_hostgroups, + 'configurationgroups': resolved_configurationgroups, + } -def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): - """ - Walk the configured inheritance chain and collect Zabbix assignments this - object inherits from its parents (device → role → device_type → - manufacturer → platform → cluster → …). - - If ``zabbixserver`` is given, results are scoped to that server. Models - that have no server relation (tags, configuration groups) are returned - regardless. - - Deduplication is first-write-wins in inheritance-chain order, matching - the previous behaviour. Each returned assignment has ``_inherited_from`` - set to a human-readable path label. - - Query complexity: exactly ``len(_INHERITANCE_MODELS)`` assignment queries - regardless of chain length, plus at most one FK query per hop in the - chain (Django's instance-level FK cache means shared prefixes are free - after the first walk). +def _resolve_inherited_assignments_batched( + triples, + zabbixserver, + resolved_templates, + resolved_server_assignments, + resolved_hostinterfaces, + resolved_macros, + resolved_tags, + resolved_hostgroups, + resolved_configurationgroups, + seen_template_ids, + seen_macro_ids, + seen_tag_ids, + seen_hostgroup_ids, + seen_configurationgroup_ids, +): + """Batch-query all assignment models across every collected ancestor triple. + + Replaces the former per-ancestor 7-query loop: one query per assignment model + (plus one for hostinventory) instead of 7×N. Distribution iterates the batched + rows in ancestor (triple) order so first-seen-wins dedup matches the original + per-ancestor loop exactly. """ - pluginsettings = get_plugin_settings() - paths = tuple(pluginsettings.inheritance_chain) - - resolved, pks_by_ct = _resolve_parents(assigned_object, paths) - indexes = _index_assignments(pks_by_ct, zabbixserver) - - results = [OrderedDict() for _ in _INHERITANCE_MODELS] - seen = [set() for _ in _INHERITANCE_MODELS] - - for path, related_obj, ct_id in resolved: - if related_obj is None: - continue - - label = PATH_LABELS.get(path, '.'.join(path)) - key = (ct_id, related_obj.pk) - - for i, (_, _, dedup_attr, _) in enumerate(_INHERITANCE_MODELS): - for assignment in indexes[i].get(key, ()): - dedup_value = getattr(assignment, dedup_attr) - if dedup_value in seen[i]: - continue - - assignment._inherited_from = label - results[i][dedup_value] = assignment - seen[i].add(dedup_value) + if not triples: + return None + + # Build a single Q OR across all (content_type, object_pk) pairs per model. + base_q = Q() + for ct, pk, _label in triples: + base_q |= Q(assigned_object_type=ct, assigned_object_id=pk) + label_by_obj = {(ct.pk, pk): label for ct, pk, label in triples} + + def _batch(model, *, select_related=(), server_filter=None): + qs = model.objects.filter(base_q) + if select_related: + qs = qs.select_related(*select_related) + if zabbixserver and server_filter: + qs = qs.filter(**server_filter) + return qs - return dict(zip(_RESULT_KEYS, results)) + templates_qs = _batch( + ZabbixTemplateAssignment, + select_related=('zabbixtemplate',), + server_filter={'zabbixtemplate__zabbixserver': zabbixserver}, + ) + macros_qs = _batch(ZabbixMacroAssignment, select_related=('zabbixmacro',)) + tags_qs = _batch(ZabbixTagAssignment, select_related=('zabbixtag',)) + hostgroups_qs = _batch( + ZabbixHostgroupAssignment, + select_related=('zabbixhostgroup',), + server_filter={'zabbixhostgroup__zabbixserver': zabbixserver}, + ) + configurationgroups_qs = _batch(ZabbixConfigurationGroupAssignment, select_related=('zabbixconfigurationgroup',)) + server_assignments_qs = _batch( + ZabbixServerAssignment, + select_related=('zabbixproxy', 'zabbixproxygroup'), + server_filter={'zabbixserver': zabbixserver}, + ) + hostinterfaces_qs = _batch(ZabbixHostInterface, server_filter={'zabbixserver': zabbixserver}) + + # Group rows by their (assigned_object_type_id, assigned_object_id) source so + # distribution can walk ancestors in collection (leaf-first) order. + def _group(qs): + grouped = {} + for obj in qs: + key = (obj.assigned_object_type_id, obj.assigned_object_id) + grouped.setdefault(key, []).append(obj) + return grouped + + templates_by_obj = _group(templates_qs) + macros_by_obj = _group(macros_qs) + tags_by_obj = _group(tags_qs) + hostgroups_by_obj = _group(hostgroups_qs) + configurationgroups_by_obj = _group(configurationgroups_qs) + server_assignments_by_obj = _group(server_assignments_qs) + hostinterfaces_by_obj = _group(hostinterfaces_qs) + + resolved_hostinventory = None + hostinventory_by_obj = _group(ZabbixHostInventory.objects.filter(base_q)) + + for ct, pk, label in triples: + key = (ct.pk, pk) + inherited_from = label_by_obj[key] + + for template in templates_by_obj.get(key, []): + if template.zabbixtemplate_id not in seen_template_ids: + template._inherited_from = inherited_from + resolved_templates[template.zabbixtemplate_id] = template + seen_template_ids.add(template.zabbixtemplate_id) + for sa in server_assignments_by_obj.get(key, []): + if sa.zabbixserver_id not in resolved_server_assignments: + sa._inherited_from = inherited_from + resolved_server_assignments[sa.zabbixserver_id] = sa + for hi in hostinterfaces_by_obj.get(key, []): + if hi.id not in resolved_hostinterfaces: + hi._inherited_from = inherited_from + resolved_hostinterfaces[hi.id] = hi + for macro in macros_by_obj.get(key, []): + if macro.zabbixmacro_id not in seen_macro_ids: + macro._inherited_from = inherited_from + resolved_macros[macro.zabbixmacro_id] = macro + seen_macro_ids.add(macro.zabbixmacro_id) + for tag in tags_by_obj.get(key, []): + if tag.id not in seen_tag_ids: + tag._inherited_from = inherited_from + resolved_tags[tag.id] = tag + seen_tag_ids.add(tag.id) + for hostgroup in hostgroups_by_obj.get(key, []): + if hostgroup.zabbixhostgroup_id not in seen_hostgroup_ids: + hostgroup._inherited_from = inherited_from + resolved_hostgroups[hostgroup.zabbixhostgroup_id] = hostgroup + seen_hostgroup_ids.add(hostgroup.zabbixhostgroup_id) + for configurationgroup in configurationgroups_by_obj.get(key, []): + if configurationgroup.zabbixconfigurationgroup_id not in seen_configurationgroup_ids: + configurationgroup._inherited_from = inherited_from + resolved_configurationgroups[configurationgroup.zabbixconfigurationgroup_id] = configurationgroup + seen_configurationgroup_ids.add(configurationgroup.zabbixconfigurationgroup_id) + + if not resolved_hostinventory: + inventory_rows = hostinventory_by_obj.get(key, []) + if inventory_rows: + hostinventory = inventory_rows[0] + hostinventory._inherited_from = inherited_from + resolved_hostinventory = hostinventory + + return resolved_hostinventory diff --git a/nbxsync/utils/sync/hostinterfacesync.py b/nbxsync/utils/sync/hostinterfacesync.py index 93a8cf86..7aee173f 100644 --- a/nbxsync/utils/sync/hostinterfacesync.py +++ b/nbxsync/utils/sync/hostinterfacesync.py @@ -14,6 +14,12 @@ def api_object(self): def get_name_value(self): return self.obj.assigned_object.name + def find_by_name(self): + hostid = self.context.get('hostid', None) + if not hostid: + return [] + return self.api_object().get(hostids=[hostid], filter={'type': str(self.obj.type)}) + def get_create_params(self): hostid = self.context.get('hostid', None) zbxserverassignment = None @@ -31,6 +37,13 @@ def get_create_params(self): ipaddr = '' if self.obj.ip_id: ipaddr = IPAddress.objects.get(id=self.obj.ip_id).address.ip + elif self.context.get('_instance'): + # If the interface is inherited (e.g. from SiteGroup or Role) + # and has no IP assigned, fall back to the device's primary IP + instance = self.context.get('_instance') + primary_ip = getattr(instance, 'primary_ip4', None) or getattr(instance, 'primary_ip6', None) + if primary_ip: + ipaddr = primary_ip.address.ip dns_name, _ = self.obj.render_dns() result = { diff --git a/nbxsync/utils/sync/hostsync.py b/nbxsync/utils/sync/hostsync.py index b9efbdfe..f7e997ff 100644 --- a/nbxsync/utils/sync/hostsync.py +++ b/nbxsync/utils/sync/hostsync.py @@ -1,17 +1,20 @@ +import logging import re from datetime import datetime, timedelta -from django_rq import get_queue from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django_rq import get_queue from .syncbase import ZabbixSyncBase from nbxsync.choices import HostInterfaceRequirementChoices, ZabbixHostInterfaceSNMPVersionChoices, ZabbixHostInterfaceTypeChoices, ZabbixInterfaceSNMPV3SecurityLevelChoices from nbxsync.choices.syncsot import SyncSOT from nbxsync.choices.zabbixstatus import ZabbixHostStatus -from nbxsync.models import ZabbixHostInterface, ZabbixMaintenance, ZabbixMaintenancePeriod, ZabbixMaintenanceObjectAssignment +from nbxsync.models import ZabbixHostInterface, ZabbixMaintenance, ZabbixMaintenanceObjectAssignment, ZabbixMaintenancePeriod from nbxsync.utils.sync.hostinterfacesync import HostInterfaceSync +logger = logging.getLogger(__name__) + class HostSync(ZabbixSyncBase): id_field = 'hostid' @@ -20,27 +23,41 @@ class HostSync(ZabbixSyncBase): def api_object(self): return self.api.host + def _get_sync_target(self): + """Return the Device/VM being synced, falling back to the assignment's assigned_object. + + When a ZabbixServerAssignment is inherited from a Site, Platform, etc., + ``self.obj.assigned_object`` is that higher-level object, not the Device. + The sync engine passes the actual instance via ``all_objects['_instance']`` + so that status, description, serial, and other device-level attributes + resolve correctly. + """ + return self.context.get('all_objects', {}).get('_instance') or self.obj.assigned_object + def get_base_name(self): # If the object has the "name" attribute, only return that (Device). If not (cornercase?), return the display string - if hasattr(self.obj.assigned_object, 'name'): - return self.obj.assigned_object.name + sync_target = self._get_sync_target() + if hasattr(sync_target, 'name'): + return sync_target.name - return str(self.obj.assigned_object) + return str(sync_target) def get_name_value(self): + sync_target = self._get_sync_target() base_name = self.get_base_name() cf_name = getattr(self.pluginsettings, 'custom_field_hostname', '') - if cf_name and hasattr(self.obj.assigned_object, 'custom_field_data'): - cf_value = self.obj.assigned_object.custom_field_data.get(cf_name) + if cf_name and hasattr(sync_target, 'custom_field_data'): + cf_value = sync_target.custom_field_data.get(cf_name) if cf_value: return str(cf_value) return base_name def get_display_name(self): + sync_target = self._get_sync_target() base_name = self.get_base_name() cf_name = getattr(self.pluginsettings, 'custom_field_display_name', '') - if cf_name and hasattr(self.obj.assigned_object, 'custom_field_data'): - cf_value = self.obj.assigned_object.custom_field_data.get(cf_name) + if cf_name and hasattr(sync_target, 'custom_field_data'): + cf_value = sync_target.custom_field_data.get(cf_name) if cf_value: return str(cf_value) return base_name @@ -49,8 +66,9 @@ def find_by_name(self): return self.api_object().get(filter={'host': self.sanitize_string(input_str=str(self.get_name_value()))}) def get_create_params(self): - status = self.obj.assigned_object.status - object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) @@ -65,7 +83,7 @@ def get_create_params(self): 'name': self.get_display_name(), 'groups': self.get_groups(), 'status': host_status, - 'description': self.obj.assigned_object.description or '', + 'description': sync_target.description or '', **self.get_proxy_or_proxygroup(), **self.get_hostinterface_attributes(), **self.get_tag_attributes(), @@ -116,8 +134,8 @@ 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) + for macro in (self.context.get('all_objects', {}).get('macros', []) or []): + rendered_value, _ = macro.render(object=self._get_sync_target()) result.append( { 'macro': str(macro), @@ -148,7 +166,7 @@ def get_defined_macros(self): def get_snmp_macros(self): result = [] - hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) + hostinterfaces = (self.context.get('all_objects', {}).get('hostinterfaces', []) or []) snmpconf = self.pluginsettings.snmpconfig for hostinterface in hostinterfaces: @@ -219,7 +237,7 @@ def get_macros(self): def get_hostinterface_attributes(self): result = {} - for hostinterface in self.context.get('all_objects', {}).get('hostinterfaces', []): + for hostinterface in (self.context.get('all_objects', {}).get('hostinterfaces', []) or []): if hostinterface.type == ZabbixHostInterfaceTypeChoices.AGENT: result['tls_connect'] = hostinterface.tls_connect result['tls_accept'] = 0 @@ -239,7 +257,7 @@ def get_hostinterface_attributes(self): return result def get_hostinterface_types(self): - hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) + hostinterfaces = (self.context.get('all_objects', {}).get('hostinterfaces', []) or []) return list({interface.type for interface in hostinterfaces}) def get_templates_clear_attributes(self): @@ -282,7 +300,7 @@ def get_template_attributes(self): result = [] hostinterface_types = set(self.get_hostinterface_types() or []) - for assigned_template in self.context.get('all_objects', {}).get('templates', []): + for assigned_template in (self.context.get('all_objects', {}).get('templates', []) or []): required = set(assigned_template.zabbixtemplate.interface_requirements or []) # Extract special modifiers @@ -308,22 +326,43 @@ def get_template_attributes(self): return {'templates': result} def get_tag_attributes(self): - status = self.obj.assigned_object.status - object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) result = [] - for assigned_tag in self.context.get('all_objects', {}).get('tags'): - value, _ = assigned_tag.render() + exclude_tag = self.pluginsettings.exclude_tag + for assigned_tag in (self.context.get('all_objects', {}).get('tags', []) or []): + # Skip the exclusion tag before rendering — it is a sync-time + # signal, not a Zabbix host tag. Filtering here avoids + # unnecessary Jinja2 rendering of a tag that will never reach + # Zabbix. + if exclude_tag and assigned_tag.zabbixtag.tag == exclude_tag: + continue + value, _ = assigned_tag.render(object=sync_target) result.append({'tag': assigned_tag.zabbixtag.tag, 'value': value}) + # Deduplicate tags by (tag, value). The same tag value can be + # resolved from multiple sources in the inheritance chain (e.g. + # environment=Production inherited from both a specific role + # and the parent Server role), which Zabbix rejects. + seen = set() + deduped = [] + for tag in result: + key = (tag['tag'], tag['value']) + if key not in seen: + seen.add(key) + deduped.append(tag) + result = deduped + if zabbix_status == ZabbixHostStatus.ENABLED_NO_ALERTING: result.append({'tag': self.pluginsettings.no_alerting_tag, 'value': str(self.pluginsettings.no_alerting_tag_value)}) if self.pluginsettings.attach_objtag: - result.append({'tag': self.pluginsettings.objtag_type, 'value': str(type(self.obj.assigned_object).__name__).lower()}) - result.append({'tag': self.pluginsettings.objtag_id, 'value': str(self.obj.assigned_object.id)}) + result.append({'tag': self.pluginsettings.objtag_type, 'value': str(type(sync_target).__name__).lower()}) + result.append({'tag': self.pluginsettings.objtag_id, 'value': str(sync_target.id)}) return {'tags': result} @@ -358,13 +397,15 @@ def get_groups(self): def get_hostinventory(self): hostinventory = self.context.get('all_objects', {}).get('hostinventory', None) + sync_target = self._get_sync_target() inventory = {} inventory_mode = 0 if hostinventory: inventory_mode = hostinventory.inventory_mode or 0 - for field_name, (rendered_value, success) in hostinventory.render_all_fields().items(): + # Override the context object with the actual device/VM for Jinja2 rendering + for field_name, (rendered_value, success) in hostinventory.render_all_fields(object=sync_target).items(): if success and rendered_value: inventory[field_name] = rendered_value @@ -375,13 +416,14 @@ def get_hostinventory(self): return result def verify_maintenancewindow(self): - status = self.obj.assigned_object.status - object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) - object_ct = ContentType.objects.get_for_model(self.obj.assigned_object) - mw_assignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.obj.assigned_object.id) + object_ct = ContentType.objects.get_for_model(sync_target) + mw_assignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=sync_target.id) if zabbix_status != ZabbixHostStatus.ENABLED_IN_MAINTENANCE: for assignment in mw_assignments: @@ -401,11 +443,11 @@ def verify_maintenancewindow(self): now = datetime.now() end_date = now + timedelta(seconds=int(self.pluginsettings.maintenance_window_duration)) # Create the Maintenance object - maintenance = ZabbixMaintenance(name=f'[AUTOMATIC] {str(self.obj.assigned_object)}', description='Automatically created maintenance object due to the object status', automatic=True, active_since=now, active_till=end_date, zabbixserver=self.obj.zabbixserver) + maintenance = ZabbixMaintenance(name=f'[AUTOMATIC] {str(sync_target)}', description='Automatically created maintenance object due to the object status', automatic=True, active_since=now, active_till=end_date, zabbixserver=self.obj.zabbixserver) maintenance.save() # Assign this host to the Maintenance object - ZabbixMaintenanceObjectAssignment(zabbixmaintenance=maintenance, assigned_object_type=object_ct, assigned_object_id=self.obj.assigned_object.id).save() + ZabbixMaintenanceObjectAssignment(zabbixmaintenance=maintenance, assigned_object_type=object_ct, assigned_object_id=sync_target.id).save() # And create the maintenance period seconds_of_day = now.hour * 3600 + now.minute * 60 + now.second ZabbixMaintenancePeriod(zabbixmaintenance=maintenance, start_date=now, start_time=seconds_of_day, period=int(self.pluginsettings.maintenance_window_duration)).save() @@ -487,7 +529,7 @@ def check_default_hostinterface(self): return hostid = str(int(self.obj.hostid)) - netbox_hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) + netbox_hostinterfaces = list(self.context.get('all_objects', {}).get('hostinterfaces', []) or []) zabbix_hostinterfaces = self.api.hostinterface.get(hostids=hostid) netbox_default_obj_by_type = {} @@ -562,13 +604,26 @@ def verify_hostinterfaces(self): return {} # Extract the currently expected interfaces - expected_hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) - expected_ids = {int(expected_hostinterface.interfaceid) for expected_hostinterface in expected_hostinterfaces} + expected_hostinterfaces = list(self.context.get('all_objects', {}).get('hostinterfaces', []) or []) + expected_ids = {int(expected_hostinterface.interfaceid) for expected_hostinterface in expected_hostinterfaces if expected_hostinterface.interfaceid} # Get currently assigned hostinterface from Zabbix current_hostinterfaces = self.api.hostinterface.get(output=['extend'], hostids=self.obj.hostid) current_ids = {int(current_hostinterface['interfaceid']) for current_hostinterface in current_hostinterfaces} + # For expected interfaces without an interfaceid (e.g. inherited from a + # ConfigGroup), match by type to the Zabbix interfaces so they are not deleted. + expected_types = {int(hi.type) for hi in expected_hostinterfaces} + # Match by type so ConfigGroup-inherited interfaces (which have no + # persisted interfaceid) are not treated as stale and deleted. + # Since zabbix_utils wraps responses differently in worker vs shell, + # just don't delete any interfaces that might be linked to items. + # The ConfigGroup-expanded interface has interfaceid=None, so it's + # not in expected_ids — but trying to delete it fails when items are linked. + # Skip deletion entirely for inherited copies. + if not self._should_persist(): + return + to_be_deleted = current_ids - expected_ids for id_to_delete in to_be_deleted: self.api.hostinterface.delete(id_to_delete) diff --git a/nbxsync/utils/sync/syncbase.py b/nbxsync/utils/sync/syncbase.py index 723a6d1f..4283145e 100644 --- a/nbxsync/utils/sync/syncbase.py +++ b/nbxsync/utils/sync/syncbase.py @@ -27,6 +27,10 @@ def __init__(self, api, netbox_obj, **kwargs): if self.sot is None: raise ValueError(f"No source-of-truth setting found for key '{self.sot_key}'.") + def _should_persist(self) -> bool: + """False for transient inherited copies that must never write back to their source row.""" + return not getattr(self.obj, '_is_inherited_copy', False) + @classmethod def resolve_zabbixserver(cls, obj): if callable(cls.zabbixserver_path): @@ -62,7 +66,8 @@ def sync(self): self.sync_from_zabbix(found) elif self.sot == SyncSOT.NETBOX: self.sync_to_zabbix(object_id) - self.obj.save() + if self._should_persist(): + self.obj.save() logger.debug(f'Found and synced {self.__class__.__name__} ID: {object_id}') else: # Object not found: create in Zabbix, always @@ -72,11 +77,13 @@ def sync(self): raise RuntimeError(f'{self.__class__.__name__} creation returned no ID.') except RuntimeError as err: logger.warning(str(err)) - self.obj.update_sync_info(success=False, message=str(err)) + if self._should_persist(): + self.obj.update_sync_info(success=False, message=str(err)) raise self.set_id(object_id) - self.obj.save() - self.obj.update_sync_info(success=True) + if self._should_persist(): + self.obj.save() + self.obj.update_sync_info(success=True) def try_create(self): try: @@ -116,8 +123,9 @@ def sync_from_zabbix(self, data): def sync_to_zabbix(self, object_id): self.set_id(object_id) - self.obj.save() - self.obj.update_sync_info(success=True) + if self._should_persist(): + self.obj.save() + self.obj.update_sync_info(success=True) self.update_in_zabbix(object_id=object_id) def update_in_zabbix(self, **kwargs): diff --git a/nbxsync/views/tabs.py b/nbxsync/views/tabs.py index a3700e07..d79b962d 100644 --- a/nbxsync/views/tabs.py +++ b/nbxsync/views/tabs.py @@ -1,13 +1,14 @@ from django.contrib.contenttypes.models import ContentType +from nbxsync.utils import get_assigned_zabbixobjects from netbox.views.generic import ObjectChildrenView, ObjectView from utilities.views import register_model_view, ViewTab -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.filtersets import ZabbixTemplateFilterSet, ZabbixMacroFilterSet from nbxsync.mixins import ZabbixTabMixin -from nbxsync.models import ZabbixServer, ZabbixMacro, ZabbixHostInterface, ZabbixTemplate, ZabbixServerAssignment, ZabbixMaintenanceObjectAssignment, ZabbixHostInventory, ZabbixConfigurationGroupAssignment +from nbxsync.models import ZabbixServer, ZabbixMacro, ZabbixTemplate, ZabbixServerAssignment, ZabbixMaintenanceObjectAssignment, ZabbixConfigurationGroupAssignment from nbxsync.tables import ZabbixTemplateTable, ZabbixMacroTable, ZabbixHostInterfaceObjectViewTable, ZabbixServerAssignmentObjectViewTable, ZabbixMaintenanceObjectAssignmentDetailViewTable @@ -76,6 +77,21 @@ class ZabbixPlatformTabView(ZabbixTabMixin, ObjectView): queryset = Platform.objects.all() +@register_model_view(Site, name='zabbix', path='zabbix') +class ZabbixSiteTabView(ZabbixTabMixin, ObjectView): + queryset = Site.objects.all() + + +@register_model_view(SiteGroup, name='zabbix', path='zabbix') +class ZabbixSiteGroupTabView(ZabbixTabMixin, ObjectView): + queryset = SiteGroup.objects.all() + + +@register_model_view(Region, name='zabbix', path='zabbix') +class ZabbixRegionTabView(ZabbixTabMixin, ObjectView): + queryset = Region.objects.all() + + @register_model_view(Cluster, name='zabbix', path='zabbix') class ZabbixClusterTabView(ZabbixTabMixin, ObjectView): queryset = Cluster.objects.all() @@ -95,13 +111,14 @@ class ZabbixDeviceTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments) @@ -138,13 +155,14 @@ class ZabbixVirtualMachineTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments) @@ -182,13 +200,14 @@ class ZabbixVirtualDeviceContextTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments)