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..b7e07914 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,39 @@ # 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 `ZabbixTemplateRule` for regex-based template (and optional hostgroup/tag) assignment by platform name (`re.search`, case-insensitive) +- Template rules support optional conjunctive criteria: `role_pattern`, `require_tags` (NetBox tag slugs) and `manufacturer` (fail-closed when set; `PROTECT` on delete). Optional hostgroup/tag FKs also use `PROTECT` +- Template rules that attach a hostgroup are shown on the Zabbix Hostgroup detail/list views +- Added a REST API endpoint for `ZabbixTemplateRule` (`/api/plugins/nbxsync/zabbixtemplaterule/`) +- Added Site/SiteGroup/Region inheritance paths (appended after role/platform so upgrades do not change Role/Platform precedence); cluster site uses `cluster._site` (available since NetBox 4.2; plugin requires ≥4.2.6) +- Added `ZabbixHostBinding`: a durable record of the Zabbix host owned by each NetBox object, so a host can still be retired after its (inherited) assignment disappears +- Added a background sync job that enumerates Devices/VMs inheriting a Zabbix server assignment, providing zero-touch provisioning for newly created inventory +- Added `allow_inherited_deletion` (default `False`) so inheritance-driven host deletions are reported with their impact before any Zabbix history is discarded +- Added `adopt_existing_hosts` (default `False`) so binding to a pre-existing Zabbix host is an explicit decision instead of a silent takeover. Requires `attach_objtag=True` (identity tags) + +### Improvements + +- Nested hostgroups: missing path segments (`A/B/C`) are created parent-first in Zabbix so permissions can inherit into subgroups +- Nested hostgroup rename: editing a static group's `ZabbixHostgroup.value` renames the Zabbix group in place via the stored `groupid` +- Configuration Group interfaces are deduplicated by interface identity (type, connect mode, port, DNS, OOB flag), so a second interface of the same Zabbix type is no longer dropped +- A failing host interface no longer hides the failure: per-interface and template-linkage failures are recorded on the assignment and reported as an aggregated job error +- Background host reconciliation collects host primary keys with queryset iterators instead of materialising full Device/VM lists +- 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) +- Default `backgroundsync.objects.interval` is 360 minutes so a full reconcile is less likely to overlap the next run + +### Bug fixes + +- Jinja2 tag and hostgroup values are rendered against the Device/VM being synchronised, not against the inheritance source (Role, Platform, Site, …) +- Tag/hostgroup Jinja context exposes `device`, `site`, `tenant`, `role`, `device_type`, and `manufacturer` aliases from the render object (covers #102; aliases follow the host during sync) +- UI previews for hierarchy assignments use a device-shaped view of the target object instead of borrowing a sample descendant device +- UI previews skip Devices/VMs carrying the configured `exclude_tag` when selecting a representative host +- VirtualMachines no longer inherit assignments via `device`-prefixed `inheritance_chain` paths (NetBox ≥4.3 `VirtualMachine.device`). Host manufacturer/role/device-type templates no longer leak onto guest VMs; Virtual Device Contexts still walk those paths +- Deleting a Device/VM in NetBox always retires its Zabbix host (via `ZabbixHostBinding`) even when `sync_enabled` is False on the assignment or server — inventory deletion is intentional retirement, not a background sync + ## [1.0.0] - Initial Release - Loads of features, :) diff --git a/docs/configuration.md b/docs/configuration.md index 90c036fe..77fb311c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,11 +55,17 @@ 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': { 'enabled': True, - 'interval': 60, # 1 hour + 'interval': 360, # 6 hours }, 'templates': { 'enabled': True, @@ -105,10 +111,64 @@ 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': '', + 'allow_inherited_deletion': False, + 'adopt_existing_hosts': False, } ``` +## Inheritance Chain + +The `inheritance_chain` setting defines which NetBox objects are traversed when resolving Zabbix assignments. Assignments (templates, tags, hostgroups, macros, proxy/server, 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: since 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`, NetBox 4.2+; plugin requires ≥4.2.6) | + +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. + +## Zabbix Template Rules + +`ZabbixTemplateRule` assigns a Zabbix template (and optionally a hostgroup and tag) when a Device or VM matches the rule. The platform name is matched with case-insensitive `re.search`. Rules run after direct and inherited assignments, so explicit `ZabbixTemplateAssignment` objects always take priority. + +Optional hostgroup/tag assignment is useful for OS-family grouping (for example a Windows rule that assigns the agent template, a `Windows` hostgroup and an `os_family=Windows` tag). Hostgroups attached by a rule appear on the Zabbix Hostgroup page under Template rules. + +| Field | Description | +|-------|-------------| +| `name` | Human-readable name | +| `pattern` | Regex matched against platform name (`re.search`, case-insensitive). Use `.*` when matching only on role, tags or manufacturer | +| `role_pattern` | Optional regex against the Device/VM role name. Empty = any role | +| `require_tags` | Optional comma-separated NetBox tag slugs (all required). Empty = any. Uses object tags, not DeviceType tags | +| `manufacturer` | Optional Manufacturer. When set, `device_type.manufacturer` must match. Empty = any. Objects without a manufacturer (e.g. VMs) do not match. Uses `PROTECT` on delete | +| `zabbixtemplate` | Template assigned when the rule matches | +| `zabbixhostgroup` | Optional hostgroup assigned on match | +| `zabbixtag` | Optional tag assigned on match | +| `enabled` | Enable/disable without deleting the rule | +| `priority` | Lower value = higher priority | + +All non-empty criteria are combined with AND. Patterns are validated on save; common nested-quantifier shapes such as `(a+)+` / `(a*){2,}` are rejected as a ReDoS guard (not a complete regex safety analyser). Platform names are capped at 64 characters (roles at 100). Optional hostgroups must belong to the same Zabbix server as the template. + +Example: `pattern=.*`, `role_pattern=^Server$`, `manufacturer=Dell`, template = Dell iDRAC by SNMP — without assigning that template on every Dell Manufacturer object. + ## Configuration values ### Source of Truth @@ -153,11 +213,13 @@ This key is used to determine if 'objects' (that is: Devices and/or Virtual Mach ##### enabled -Either true or false (default: True) +Either true or false (default: True). When enabled, a periodic job enumerates all Devices and VirtualMachines that inherit a `ZabbixServerAssignment` (direct or from SiteGroup/Site/Region/Role/Platform/etc.) and enqueues each for sync. ##### interval -Used to determine the interval to sync Devices and Virtual Machines to/from Zabbix, in minutes (default: 60) +Used to determine the interval to sync Devices and Virtual Machines to/from Zabbix, in minutes (default: 360 / 6 hours) + +Size the interval so a full reconciliation finishes well before the next one starts, otherwise runs queue up behind each other. Throughput depends on your Zabbix server, the number of interfaces and templates per host, and network latency, so measure it on your own installation: the job logs `duration_seconds` and the number of hosts enqueued on every run. #### templates @@ -270,6 +332,43 @@ 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 an already synced host is removed from Zabbix. Exclusion is an explicit operator decision, so — like a `statusmapping` entry that maps to `deleted` — it always deletes and is not affected by `allow_inherited_deletion` (see below). + +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). + +### allow_inherited_deletion + +Controls whether losing every `ZabbixServerAssignment` can delete an existing Zabbix host — for example because a Site was moved into another SiteGroup. Such a deletion can be caused by an edit far away from the device, and deleting a Zabbix host discards its measurement history. + +While disabled (the default), nbxsync keeps those hosts and logs each one it would have deleted, with the reason and the Zabbix host ID: + +``` +Not deleting Zabbix host for switch-01 on Zabbix EU (hostid 10842): no remaining Zabbix server assignment requires +deletion, but allow_inherited_deletion is disabled. Enable it to let nbxsync remove the host and its history. +``` + +Review those log lines after restructuring the site hierarchy, then set the setting to `True` to let nbxsync reconcile. Explicit deletions are unaffected: a `statusmapping` entry that maps to `deleted`, an `exclude_tag` match, and deleting the Device/VM in NetBox always remove the Zabbix host — including when `sync_enabled` is False (inventory deletion is retirement, not a background sync). + +Defaults to `False`. + +### adopt_existing_hosts + +Controls whether nbxsync may bind to a Zabbix host it did not create. During sync, a host whose technical name matches and that carries the managed identity tags (`nb_type`/`nb_id`) can either be adopted or reported as a conflict. + +Adoption requires `attach_objtag=True`: without those identity tags on the Zabbix host, adoption cannot safely prove the host belongs to this NetBox object. + +Adoption makes NetBox authoritative over that host immediately: its interfaces, templates, macros, tags and inventory are overwritten on the next sync. While disabled (the default), the sync fails with an actionable message naming the host and the setting, and nothing in Zabbix is changed. + +Enable it for a controlled migration of hosts that were provisioned by an earlier tool, then turn it off again. + +Defaults to `False`. + ## 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..2c11837d 100644 --- a/docs/dynamic_values.md +++ b/docs/dynamic_values.md @@ -12,15 +12,11 @@ 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: +During host synchronisation, tag, hostgroup, macro and host-inventory templates are rendered against the Device, VDC or VirtualMachine being synced — even when the assignment is inherited from a Role, Platform, Site or similar. That way templates such as `{{ object.name }}` or `{{ object.site.name }}` resolve to the host, not to the inheritance source. -```jinja2 -{{ object.site.name }} -``` - -on a `DeviceType` does not make sense, because a `DeviceType` does not have a `Site` attribute. +In the UI preview (and when syncing a hostgroup assignment against a hierarchy object itself), `object` is the assignment target. Hierarchy targets such as DeviceRole or Site are exposed in a device-shaped form (`object.role`, `object.site`, …) so templates like `Roles/{{ object.role.name }}` work without borrowing a descendant device. Targets that cannot fill a single device-shaped value leave the template unresolved. ## Context @@ -30,7 +26,14 @@ Rendering a value is always performed within a context, which provides access to {{ object.site.name }} (via {{ tag }}) ``` -would be perfectly valid. +would be perfectly valid, as would the shorthand aliases (same source as `object`): + +```jinja2 +{{ site.name }} / {{ device.name }} +{{ device_type.model }} — {{ manufacturer.name }} +``` + +Those aliases are filled from the render `object` after any `object=` override, so during host sync they follow the Device/VM, not the Role/Site assignment row. They are omitted when the attribute does not exist (`device` is only set for Device/VM/VDC). ### Tag @@ -38,11 +41,17 @@ 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 | +| device | render object | Set only when `object` is a Device, VM or VDC | +| site | `object.site` | Present when the render object has a site | +| tenant | `object.tenant` | Present when the render object has a tenant | +| role | `object.role` | Present when the render object has a role | +| device_type | `object.device_type` | Present when the render object has a device type | +| manufacturer | `device_type.manufacturer` | Present when `device_type` has a manufacturer | +| 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 @@ -50,9 +59,15 @@ 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 | +| object | assigned_object | The assignment target. During host sync this is the Device/VM/VDC being synced | +| device | render object | Set only when `object` is a Device, VM or VDC | +| site | `object.site` | Present when the render object has a site | +| tenant | `object.tenant` | Present when the render object has a tenant | +| role | `object.role` | Present when the render object has a role | +| device_type | `object.device_type` | Present when the render object has a device type | +| manufacturer | `device_type.manufacturer` | Present when `device_type` has a manufacturer | +| value | zabbixhostgroup.value | The value of the Zabbix Hostgroup (typically the Jinja2 template) | +| name | zabbixhostgroup.name | The name of the Zabbix Hostgroup | ### Host Inventory @@ -60,10 +75,9 @@ Each field on a `ZabbixHostInventory` record is rendered individually. The conte | Key | Value | Explanation | |----------|------------------|--------------------------------------------------------------| -| `object` | assigned_object | The Device, VDC, or VirtualMachine this inventory belongs to | +| `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 always 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. @@ -73,4 +87,4 @@ Note that each field has a maximum character length enforced at render time, val The `inventory_mode` field determines how Zabbix handles the inventory data. - Manual (default) means only nbxSync writes to inventory. - Automatic causes Zabbix to overwrite inventory from item values (this conflicts with nbxSync's writes and is not recommended). - - Disabled turns off inventory entirely for the host. \ No newline at end of file + - Disabled turns off inventory entirely for the host. diff --git a/docs/installation.md b/docs/installation.md index 629de65e..3ffe43f9 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,11 +85,16 @@ PLUGINS_CONFIG = { ['cluster'], ['cluster', 'type'], ['type'], + ['device', 'site'], + ['site'], + ['site', 'group'], + ['site', 'region'], + ['cluster', '_site'], ], 'backgroundsync': { 'objects': { 'enabled': True, - 'interval': 60, # 1 hour + 'interval': 360, # 6 hours }, 'templates': { 'enabled': True, @@ -112,10 +117,15 @@ PLUGINS_CONFIG = { 'objtag_id': 'nb_id', 'custom_field_hostname': '', 'custom_field_display_name': '', + 'exclude_tag': '', + 'allow_inherited_deletion': False, + 'adopt_existing_hosts': False, } } ``` +See [Configuration](configuration.md) for the meaning of each setting. + #### Run migrations ```python diff --git a/docs/models.md b/docs/models.md index 9a7b0750..a52bfdfe 100644 --- a/docs/models.md +++ b/docs/models.md @@ -44,10 +44,30 @@ 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. +### `ZabbixTemplateRule` + +Regex-driven automatic assignment of a Zabbix template (and optionally a hostgroup and tag) when a Device or VM matches the rule. The pattern is matched against the Platform name only (`re.search`, case-insensitive). Evaluated after direct and inherited `ZabbixTemplateAssignment` rows. + +| Field | Type | Description | +|--------------------|--------------|-------------| +| `name` | CharField | Human-readable name | +| `description` | CharField | Optional description | +| `pattern` | CharField | Regex matched against Platform name (`re.search`, case-insensitive) | +| `role_pattern` | CharField | Optional regex against Device/VM role name; empty = any | +| `require_tags` | CharField | Optional comma-separated NetBox tag slugs (all required); empty = any | +| `manufacturer` | ForeignKey | Optional `dcim.Manufacturer`; empty = any; `PROTECT` on delete | +| `zabbixtemplate` | ForeignKey | Template assigned on match | +| `zabbixhostgroup` | ForeignKey | Optional hostgroup assigned on match (`PROTECT`) | +| `zabbixtag` | ForeignKey | Optional tag assigned on match (`PROTECT`) | +| `enabled` | BooleanField | Soft enable/disable | +| `priority` | IntegerField | Lower = higher priority | + +See [Zabbix Template Rules](configuration.md#zabbix-template-rules) for matching behaviour and the hostgroup UI. + ### `ZabbixMacro` Defines a user macro at the Zabbix Server or Zabbix Template level. These macros apply globally to all hosts on the server or to all hosts using the template. @@ -62,7 +82,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 +106,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 @@ -104,12 +124,35 @@ Links a NetBox object to a Zabbix server/host/proxy. --- +### `ZabbixHostBinding` + +Durable mapping from a NetBox Device, VDC or VirtualMachine to a Zabbix `hostid` on a given server. Created and removed by host sync/delete so a host can still be retired after an inherited assignment disappears. There is no operator UI or API. + +| Field | Type | Description | +|-------------------|--------------|-------------| +| `zabbixserver` | ForeignKey | Zabbix server | +| `assigned_object` | Generic FK | Device, VDC or VirtualMachine | +| `hostid` | PositiveBigIntegerField | Zabbix host ID | +| `hostname` | CharField | Last known hostname (informational) | + +--- + ### `ZabbixHostgroup` / `ZabbixHostgroupAssignment` Defines host groups and their mapping. - `ZabbixHostgroup`: static groups defined in Zabbix -- `ZabbixHostgroupAssignment`: assign them to NetBox objects +- `ZabbixHostgroupAssignment`: assign them to NetBox objects (Jinja `value` or static) + +A hostgroup can also be attached by a `ZabbixTemplateRule.zabbixhostgroup` when the rule matches. The hostgroup detail page lists those rules under **Template rules**; the list view shows assignment and rule counts. + +#### Nested host groups + +Zabbix nesting is a naming convention (`Network/Region/Site`); Zabbix stores no parent relation between groups. + +- Creating `A/B/C` never creates `A` or `A/B`. nbxsync therefore creates missing path segments parent-first. +- To rename a group in place, edit the `ZabbixHostgroup` `value` (the Zabbix-facing name). Changing a Jinja template produces a new path; hosts migrate on the next sync and the old path may remain as an empty group. +- Path segments must be non-empty (no leading, trailing or double slashes). --- @@ -169,42 +212,54 @@ 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. --- -## 🧬 Inheritance Logic +## Inheritance Logic 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: since 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 | diff --git a/docs/permissions.md b/docs/permissions.md index b3618128..b525da5d 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 and similar targets. Template Rules can also assign templates by platform/role/tags/manufacturer. **Typical permissions:** @@ -326,6 +326,37 @@ nbxSync integrates with the [Netbox Permission system](https://netbox.readthedoc - delete_zabbixtemplateassignment - view_zabbixtemplateassignment +## Zabbix Template Rule + +**What it is:** A regex rule that assigns a Zabbix template (and optionally a host group and tag) based on Platform name and optional role, tag and manufacturer criteria. + +**How it’s used:** Covers platform names that change over time — firmware or build numbers in the name — without curating a template assignment per variant. Compound criteria can also scope a vendor BMC template (for example Dell ∧ Server) without assigning it on every Manufacturer object. + +**Typical permissions:** + +- _view_ for operators verifying why a host received a template. +- _add/change/delete_ for engineers owning the monitoring policy; a rule applies to every matching host, so treat it like a global policy object. + +### Permissions + +- add_zabbixtemplaterule +- change_zabbixtemplaterule +- delete_zabbixtemplaterule +- view_zabbixtemplaterule + +## Zabbix Host Binding + +**What it is:** The durable record of which Zabbix host a NetBox object owns on a given Zabbix server. + +**How it’s used:** Maintained automatically by host sync and delete. There is intentionally no form, table, view or API — bindings are not edited by operators. Django model permissions exist only because NetBox registers them for every model; they are not used in normal workflows. + +### Permissions + +- add_zabbixhostbinding +- change_zabbixhostbinding +- delete_zabbixhostbinding +- view_zabbixhostbinding + ## Zabbix Configuration Group **What it is:** Groups together multiple Zabbix objects which are then replicated to all Assigned Objects @@ -346,9 +377,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..07e48886 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. +You can also use a **Zabbix Template Rule** (`Zabbix` → `Zabbix Template Rules`) to assign a template (and optionally a hostgroup and tag) when platform / role / NetBox tags / manufacturer criteria match. Rules run after direct and inherited assignments; see [Zabbix Template Rules](configuration.md#zabbix-template-rules). ## Debugging @@ -99,7 +100,7 @@ On the `Zabbix` tab of any Device, VDC, or Virtual Machine, a **Sync** button ap ### Background system job -The `Zabbix Sync Hosts job` runs automatically at the interval configured by `backgroundsync.objects.interval` (default: every 60 minutes). It iterates every `ZabbixServerAssignment` in NetBox and enqueues a `synchost` job for each unique Device, VDC, or VM (subject to both `sync_enabled` flags being `True`). +The `Zabbix Sync Hosts job` runs automatically at the interval configured by `backgroundsync.objects.interval` (default: every 360 minutes). It enumerates Devices, VDCs and VirtualMachines that inherit a `ZabbixServerAssignment` (directly or via Site/SiteGroup/Region/Role/Platform/etc.), and also reconciles hosts that still have a `ZabbixHostBinding`, then enqueues a `synchost` job for each (subject to both `sync_enabled` flags being `True`). ### REST API diff --git a/docs/signals.md b/docs/signals.md index e0e8b259..365685e2 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -13,7 +13,7 @@ When a Device or VirtualMachine is deleted in NetBox, a `pre_delete` signal fire - `ZabbixTagAssignment` - `ZabbixMacroAssignment` -If **NetBox is the Source of Truth** for hosts (`sot.host = 'netbox'`), a `deletehost` background job is also enqueued on the RQ `low` queue, which will delete the corresponding host from Zabbix once a worker picks it up. +If **NetBox is the Source of Truth** for hosts (`sot.host = 'netbox'`), a `deletehost` background job is also enqueued on the RQ `low` queue, which deletes the corresponding Zabbix host via `ZabbixHostBinding`. This retirement runs even when `sync_enabled` is False on the assignment or server: deleting the NetBox object is an intentional decommission, not a background sync. If **Zabbix is the Source of Truth** for hosts, the `ZabbixServerAssignment` is deleted from NetBox instead, and no deletion is sent to Zabbix. 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/docs/zabbixmaintenance.md b/docs/zabbixmaintenance.md index 2378ccac..5bec9e95 100644 --- a/docs/zabbixmaintenance.md +++ b/docs/zabbixmaintenance.md @@ -144,7 +144,7 @@ Each object can only be assigned once to a given maintenance window. !!! note When assigning a `ZabbixHostgroup`, only hostgroups with **static** values are supported. Hostgroups whose `value` field contains a Jinja2 template cannot be resolved without a device context, so they cannot be used here. -When the maintenance is synced, the assigned Devices, VDCs, and VirtualMachines are resolved to their Zabbix `hostid` values via their `ZabbixServerAssignment`. Objects that have not yet been synchronized to Zabbix (no `hostid`) are silently skipped. +When the maintenance is synced, the assigned Devices, VDCs, and VirtualMachines are resolved to their Zabbix `hostid` values via `ZabbixHostBinding`, falling back to a legacy `ZabbixServerAssignment.hostid` when no binding exists yet. Objects that have not yet been synchronized to Zabbix (no `hostid`) are silently skipped. ## `ZabbixMaintenanceTagAssignment` 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/__init__.py b/nbxsync/api/serializers/__init__.py index b95a1c8e..150ecef7 100644 --- a/nbxsync/api/serializers/__init__.py +++ b/nbxsync/api/serializers/__init__.py @@ -6,6 +6,7 @@ from .zabbixserverassignment import * from .zabbixtemplate import * from .zabbixtemplateassignment import * +from .zabbixtemplaterule import * from .zabbixmacro import * from .zabbixmacroassignment import * from .zabbixtag import * diff --git a/nbxsync/api/serializers/zabbixtemplaterule.py b/nbxsync/api/serializers/zabbixtemplaterule.py new file mode 100644 index 00000000..580c8eb4 --- /dev/null +++ b/nbxsync/api/serializers/zabbixtemplaterule.py @@ -0,0 +1,50 @@ +from rest_framework import serializers + +from dcim.api.serializers import ManufacturerSerializer +from netbox.api.serializers import NetBoxModelSerializer + +from nbxsync.api.serializers.zabbixhostgroup import ZabbixHostgroupSerializer +from nbxsync.api.serializers.zabbixtag import ZabbixTagSerializer +from nbxsync.api.serializers.zabbixtemplate import ZabbixTemplateSerializer +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleSerializer',) + + +class ZabbixTemplateRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='plugins-api:nbxsync-api:zabbixtemplaterule-detail') + zabbixtemplate = ZabbixTemplateSerializer(nested=True) + zabbixhostgroup = ZabbixHostgroupSerializer(nested=True, required=False, allow_null=True) + zabbixtag = ZabbixTagSerializer(nested=True, required=False, allow_null=True) + manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = ZabbixTemplateRule + fields = ( + 'url', + 'id', + 'display', + 'name', + 'description', + 'pattern', + 'role_pattern', + 'require_tags', + 'manufacturer', + 'zabbixtemplate', + 'zabbixhostgroup', + 'zabbixtag', + 'enabled', + 'priority', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) + brief_fields = ( + 'url', + 'id', + 'display', + 'name', + 'pattern', + 'enabled', + ) diff --git a/nbxsync/api/urls.py b/nbxsync/api/urls.py index 9c70e9c2..0c0ef5bd 100644 --- a/nbxsync/api/urls.py +++ b/nbxsync/api/urls.py @@ -5,6 +5,7 @@ ZabbixServerAssignmentViewSet, ZabbixTemplateViewSet, ZabbixTemplateAssignmentViewSet, + ZabbixTemplateRuleViewSet, ZabbixMacroViewSet, ZabbixMacroAssignmentViewSet, ZabbixMacroAssignmentViewSet, @@ -32,6 +33,7 @@ router.register('zabbixserverassignment', ZabbixServerAssignmentViewSet) router.register('zabbixtemplate', ZabbixTemplateViewSet) router.register('zabbixtemplateassignment', ZabbixTemplateAssignmentViewSet) +router.register('zabbixtemplaterule', ZabbixTemplateRuleViewSet) router.register('zabbixmacro', ZabbixMacroViewSet) router.register('zabbixmacroassignment', ZabbixMacroAssignmentViewSet) router.register('zabbixtag', ZabbixTagViewSet) diff --git a/nbxsync/api/views/__init__.py b/nbxsync/api/views/__init__.py index 75f298c2..26dffc4d 100644 --- a/nbxsync/api/views/__init__.py +++ b/nbxsync/api/views/__init__.py @@ -4,6 +4,7 @@ from .zabbixserverassignment import * from .zabbixtemplate import * from .zabbixtemplateassignment import * +from .zabbixtemplaterule import * from .zabbixtag import * from .zabbixtagassignment import * from .zabbixhostinterface import * diff --git a/nbxsync/api/views/zabbixsync.py b/nbxsync/api/views/zabbixsync.py index 03efdb19..31942163 100644 --- a/nbxsync/api/views/zabbixsync.py +++ b/nbxsync/api/views/zabbixsync.py @@ -1,11 +1,12 @@ +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import get_object_or_404 from django_rq import get_queue +from drf_spectacular.utils import extend_schema + from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ViewSet -from django.apps import apps -from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP @@ -34,7 +35,14 @@ def create(self, request, **kwargs): Model = OBJECT_TYPE_MODEL_MAP[obj_type] instance = get_object_or_404(Model, pk=obj_id) + content_type = ContentType.objects.get_for_model(instance) queue = get_queue('low') - queue.enqueue_job(queue.create_job(func='nbxsync.worker.synchost', args=[instance], timeout=9000)) + queue.enqueue_job( + queue.create_job( + func='nbxsync.worker.synchost', + args=[content_type.app_label, content_type.model, instance.pk], + timeout=9000, + ) + ) return Response({'count': 1, 'results': [{'scheduled': True}]}, status=202) diff --git a/nbxsync/api/views/zabbixtemplaterule.py b/nbxsync/api/views/zabbixtemplaterule.py new file mode 100644 index 00000000..4345dd0f --- /dev/null +++ b/nbxsync/api/views/zabbixtemplaterule.py @@ -0,0 +1,13 @@ +from netbox.api.viewsets import NetBoxModelViewSet + +from nbxsync.api.serializers import ZabbixTemplateRuleSerializer +from nbxsync.filtersets import ZabbixTemplateRuleFilterSet +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleViewSet',) + + +class ZabbixTemplateRuleViewSet(NetBoxModelViewSet): + queryset = ZabbixTemplateRule.objects.all().select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + serializer_class = ZabbixTemplateRuleSerializer + filterset_class = ZabbixTemplateRuleFilterSet 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..07eb16fe 100644 --- a/nbxsync/constants/assignment_type_to_field.py +++ b/nbxsync/constants/assignment_type_to_field.py @@ -1,4 +1,4 @@ -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.models import ZabbixHostgroup, ZabbixServer, ZabbixTag, ZabbixTemplate, ZabbixConfigurationGroup @@ -14,6 +14,9 @@ Cluster: 'cluster', ClusterType: 'clustertype', VirtualDeviceContext: 'virtualdevicecontext', + Site: 'site', + SiteGroup: 'sitegroup', + Region: 'region', ZabbixConfigurationGroup: 'zabbixconfigurationgroup', } @@ -28,6 +31,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/filtersets/__init__.py b/nbxsync/filtersets/__init__.py index 9af1a51b..476bc851 100644 --- a/nbxsync/filtersets/__init__.py +++ b/nbxsync/filtersets/__init__.py @@ -18,3 +18,5 @@ from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroup import * from .zabbixconfigurationgroupassignment import * + +from .zabbixtemplaterule import * \ No newline at end of file diff --git a/nbxsync/filtersets/zabbixtemplaterule.py b/nbxsync/filtersets/zabbixtemplaterule.py new file mode 100644 index 00000000..30041e2f --- /dev/null +++ b/nbxsync/filtersets/zabbixtemplaterule.py @@ -0,0 +1,38 @@ +from django.db.models import Q +from django_filters import CharFilter, ModelMultipleChoiceFilter + +from dcim.models import Manufacturer +from netbox.filtersets import NetBoxModelFilterSet + +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleFilterSet',) + + +class ZabbixTemplateRuleFilterSet(NetBoxModelFilterSet): + q = CharFilter(method='search', label='Search') + name = CharFilter(lookup_expr='icontains') + description = CharFilter(lookup_expr='icontains') + pattern = CharFilter(lookup_expr='icontains') + role_pattern = CharFilter(lookup_expr='icontains') + require_tags = CharFilter(lookup_expr='icontains') + manufacturer_id = ModelMultipleChoiceFilter( + field_name='manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = ModelMultipleChoiceFilter( + field_name='manufacturer__name', + to_field_name='name', + queryset=Manufacturer.objects.all(), + label='Manufacturer (name)', + ) + + class Meta: + model = ZabbixTemplateRule + fields = ('id', 'name', 'description', 'pattern', 'role_pattern', 'require_tags', 'manufacturer', 'enabled', 'priority') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value) | Q(pattern__icontains=value) | Q(role_pattern__icontains=value) | Q(require_tags__icontains=value) | Q(manufacturer__name__icontains=value)).distinct() diff --git a/nbxsync/forms/__init__.py b/nbxsync/forms/__init__.py index 938b3478..ff4402e0 100644 --- a/nbxsync/forms/__init__.py +++ b/nbxsync/forms/__init__.py @@ -20,3 +20,5 @@ from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroup import * from .zabbixconfigurationgroupassignment import * + +from .zabbixtemplaterule import * \ No newline at end of file diff --git a/nbxsync/forms/zabbixconfigurationgroupassignment.py b/nbxsync/forms/zabbixconfigurationgroupassignment.py index 987282e3..61c28e51 100644 --- a/nbxsync/forms/zabbixconfigurationgroupassignment.py +++ b/nbxsync/forms/zabbixconfigurationgroupassignment.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 @@ -23,6 +23,9 @@ 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')) 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('zabbixconfigurationgroup', name=_('Generic')), @@ -31,8 +34,11 @@ class ZabbixConfigurationGroupAssignmentForm(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')), ), - name=_('Device Assignment'), + name=_('Assignment'), ), ) @@ -43,6 +49,9 @@ class Meta: 'device', 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', ) @property 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..bcf60a94 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,12 +281,35 @@ class Meta: 'name', 'notes', 'device', + 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', + 'platform', + 'role', + 'devicetype', + 'manufacturer', + 'cluster', + 'clustertype', + 'zabbixconfigurationgroup', ) @property def assignable_fields(self): - return list(ASSIGNMENT_TYPE_TO_FIELD_NBOBJS.values()) + """Assignment targets that are object pickers on this form. + + ``ASSIGNMENT_TYPE_TO_FIELD_NBOBJS`` maps extras.Tag to ``tag``. This + form's ``tag`` field is the Zabbix inventory string, not a NetBox Tag + picker. Only ModelChoiceFields count, so filling the inventory tag + no longer looks like a second assignment. + """ + names = [] + for name in ASSIGNMENT_TYPE_TO_FIELD_NBOBJS.values(): + field = self.fields.get(name) + if isinstance(field, forms.ModelChoiceField): + names.append(name) + return names def __init__(self, *args, **kwargs): instance = kwargs.get('instance') @@ -273,6 +317,8 @@ def __init__(self, *args, **kwargs): if instance and instance.assigned_object: for model_class, field in ASSIGNMENT_TYPE_TO_FIELD_NBOBJS.items(): + if field == 'tag': + continue if isinstance(instance.assigned_object, model_class): initial[field] = instance.assigned_object break @@ -282,6 +328,8 @@ def __init__(self, *args, **kwargs): content_type = ContentType.objects.get(pk=initial['assigned_object_type']) obj = content_type.get_object_for_this_type(pk=initial['assigned_object_id']) for model_class, field in ASSIGNMENT_TYPE_TO_FIELD_NBOBJS.items(): + if field == 'tag': + continue if isinstance(obj, model_class): initial[field] = obj.pk break @@ -296,7 +344,7 @@ def clean(self): super().clean() selected = [field for field in self.assignable_fields if self.cleaned_data.get(field)] if len(selected) > 1: - raise forms.ValidationError({selected[1]: _('Zabbox Host Inventory can only be assigned to one object.')}) + raise forms.ValidationError({selected[1]: _('Zabbix Host Inventory can only be assigned to one object.')}) elif selected: self.instance.assigned_object = self.cleaned_data[selected[0]] else: 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/forms/zabbixtemplaterule.py b/nbxsync/forms/zabbixtemplaterule.py new file mode 100644 index 00000000..e98cf462 --- /dev/null +++ b/nbxsync/forms/zabbixtemplaterule.py @@ -0,0 +1,138 @@ +from django import forms +from django.utils.translation import gettext as _ + +from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm +from utilities.forms.fields import DynamicModelChoiceField +from utilities.forms.rendering import FieldSet + +from dcim.models import Manufacturer + +from nbxsync.models import ZabbixHostgroup, ZabbixServer, ZabbixTag, ZabbixTemplate, ZabbixTemplateRule + +__all__ = ( + 'ZabbixTemplateRuleForm', + 'ZabbixTemplateRuleFilterForm', + 'ZabbixTemplateRuleBulkEditForm', +) + + +class ZabbixTemplateRuleForm(NetBoxModelForm): + # Form-only filter so NetBox APISelect can cascade template/hostgroup choices. + # Not persisted on ZabbixTemplateRule (server is implied by the template). + zabbixserver = DynamicModelChoiceField( + queryset=ZabbixServer.objects.all(), + required=False, + selector=True, + label=_('Zabbix Server'), + help_text=_('Filters the template and hostgroup lists. Not stored on the rule.'), + ) + zabbixtemplate = DynamicModelChoiceField( + queryset=ZabbixTemplate.objects.all(), + selector=True, + label=_('Zabbix Template'), + query_params={'zabbixserver_id': '$zabbixserver'}, + ) + zabbixhostgroup = DynamicModelChoiceField( + queryset=ZabbixHostgroup.objects.all(), + required=False, + selector=True, + label=_('Zabbix Hostgroup'), + query_params={'zabbixserver_id': '$zabbixserver'}, + ) + zabbixtag = DynamicModelChoiceField(queryset=ZabbixTag.objects.all(), required=False, selector=True, label=_('Zabbix Tag')) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + selector=True, + label=_('Manufacturer'), + help_text=_('Optional. When set, only Devices whose device type manufacturer matches. Empty = any. VMs fail closed.'), + ) + + class Meta: + model = ZabbixTemplateRule + fields = ( + 'name', + 'description', + 'pattern', + 'role_pattern', + 'require_tags', + 'manufacturer', + 'zabbixtemplate', + 'zabbixhostgroup', + 'zabbixtag', + 'enabled', + 'priority', + ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance and getattr(instance, 'zabbixtemplate_id', None): + initial.setdefault('zabbixserver', instance.zabbixtemplate.zabbixserver_id) + kwargs['initial'] = initial + super().__init__(*args, **kwargs) + # zabbixserver is form-only; keep it next to the fields it filters. + # Preserve any fields NetBoxModelForm added after Meta.fields (custom + # fields, tags, changelog_message, …) — dropping them silently breaks + # create/edit tests and CF persistence in the UI. + preferred = ( + 'name', + 'description', + 'pattern', + 'role_pattern', + 'require_tags', + 'manufacturer', + 'zabbixserver', + 'zabbixtemplate', + 'zabbixhostgroup', + 'zabbixtag', + 'enabled', + 'priority', + ) + preferred_set = set(preferred) + ordered = [(name, self.fields[name]) for name in preferred if name in self.fields] + remaining = [(name, field) for name, field in self.fields.items() if name not in preferred_set] + self.fields = type(self.fields)(ordered + remaining) + + +class ZabbixTemplateRuleFilterForm(NetBoxModelFilterSetForm): + model = ZabbixTemplateRule + fieldsets = ( + FieldSet('q', 'filter_id'), + FieldSet('name', 'description', 'pattern', 'role_pattern', 'require_tags', 'manufacturer', 'enabled', name=_('Zabbix Template Rule')), + ) + + manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, label=_('Manufacturer')) + enabled = forms.NullBooleanField(required=False, label=_('Enabled')) + + +class ZabbixTemplateRuleBulkEditForm(NetBoxModelBulkEditForm): + model = ZabbixTemplateRule + + description = forms.CharField(label=_('Description'), max_length=200, required=False) + pattern = forms.CharField(label=_('Pattern'), max_length=500, required=False) + role_pattern = forms.CharField(label=_('Role Pattern'), max_length=500, required=False) + require_tags = forms.CharField(label=_('Require Tags'), max_length=200, required=False) + manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) + zabbixtemplate = forms.ModelChoiceField(queryset=ZabbixTemplate.objects.all(), required=False, label=_('Zabbix Template')) + zabbixhostgroup = forms.ModelChoiceField(queryset=ZabbixHostgroup.objects.all(), required=False, label=_('Zabbix Hostgroup')) + zabbixtag = forms.ModelChoiceField(queryset=ZabbixTag.objects.all(), required=False, label=_('Zabbix Tag')) + enabled = forms.NullBooleanField(required=False, label=_('Enabled')) + priority = forms.IntegerField(required=False, label=_('Priority')) + + fieldsets = ( + FieldSet( + 'description', + 'pattern', + 'role_pattern', + 'require_tags', + 'manufacturer', + 'zabbixtemplate', + 'zabbixhostgroup', + 'zabbixtag', + 'enabled', + 'priority', + name=_('Zabbix Template Rule'), + ), + ) + nullable_fields = ('description', 'role_pattern', 'require_tags', 'manufacturer', 'zabbixhostgroup', 'zabbixtag') diff --git a/nbxsync/jinja_context.py b/nbxsync/jinja_context.py new file mode 100644 index 00000000..d8d68977 --- /dev/null +++ b/nbxsync/jinja_context.py @@ -0,0 +1,104 @@ +"""Device-shaped Jinja context for hierarchy-level hostgroup/tag assignments. + +Sync always passes the Device/VM as ``object`` (see HostSync). UI preview and +HostGroupSync render against the assignment target itself. Templates in the +wild are written for the device-shaped namespace (``object.role.name``, +``object.site.name``, …), so a DeviceRole/Site/Platform target must expose +those same paths — not by borrowing an arbitrary descendant device, but by +wrapping the target so its own identity fills the matching slot. + +Targets that cannot fill a single unambiguous device-shaped value (SiteGroup, +Region, Manufacturer, …) are returned unchanged; templates that need a Device +then fail cleanly and the UI shows the raw template string. + +``related_template_context`` adds the #102 shorthand keys (``device``, +``site``, ``tenant``, ``role``, ``device_type``, ``manufacturer``) from that +same render object. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + + +_DEVICE_LIKE = frozenset({'Device', 'VirtualMachine', 'VirtualDeviceContext'}) + + +def wrap_assignment_object(target: Any) -> Any: + """Return ``target`` in the namespace device-context Jinja templates expect. + + Explicit ``object=`` overrides from sync are applied by the caller after + this helper runs, so this only affects the default (assignment-target) + render path. + """ + if target is None: + return None + + model_name = type(target).__name__ + if model_name in _DEVICE_LIKE: + return target + + if model_name == 'DeviceRole': + # Roles/{{ object.role.name }} and Roles/{{ object.name }} + return SimpleNamespace(role=target, name=getattr(target, 'name', str(target))) + + if model_name == 'Platform': + return SimpleNamespace(platform=target, name=getattr(target, 'name', str(target))) + + if model_name == 'Site': + # Sites/{{ object.site.group.name }}/{{ object.site.name }} + return SimpleNamespace(site=target, name=getattr(target, 'name', str(target))) + + if model_name == 'Cluster': + return SimpleNamespace(cluster=target, name=getattr(target, 'name', str(target))) + + if model_name == 'ClusterType': + return SimpleNamespace(cluster=SimpleNamespace(type=target), name=getattr(target, 'name', str(target))) + + if model_name == 'DeviceType': + return SimpleNamespace(device_type=target, name=getattr(target, 'name', str(target))) + + if model_name == 'Manufacturer': + return SimpleNamespace( + device_type=SimpleNamespace(manufacturer=target), + name=getattr(target, 'name', str(target)), + ) + + # SiteGroup / Region / unknown: no single device-shaped binding. + return target + + +_RELATED_ATTRS = ('site', 'tenant', 'role', 'device_type') + + +def related_template_context(obj: Any) -> dict[str, Any]: + """Top-level Jinja aliases for related NetBox objects (#102). + + Derived from the render ``object`` — the Device/VM/VDC during host sync, + or the (possibly wrapped) assignment target in the UI preview. Never from + the assignment row itself: a Role-level tag rendered with ``object=device`` + must expose that device's site/role, not the Role assignment target. + + ``device`` is set only for Device/VM/VDC hosts. Hierarchy previews omit it + so a Site assignment is not advertised as ``device``. Missing attributes + are omitted so templates fail cleanly. + """ + if obj is None: + return {} + + context: dict[str, Any] = {} + if type(obj).__name__ in _DEVICE_LIKE: + context['device'] = obj + + for attr in _RELATED_ATTRS: + value = getattr(obj, attr, None) + if value is not None: + context[attr] = value + + device_type = context.get('device_type') + manufacturer = getattr(device_type, 'manufacturer', None) if device_type is not None else None + if manufacturer is not None: + context['manufacturer'] = manufacturer + + return context diff --git a/nbxsync/jobs/deletehost.py b/nbxsync/jobs/deletehost.py index 7994db26..6920637a 100644 --- a/nbxsync/jobs/deletehost.py +++ b/nbxsync/jobs/deletehost.py @@ -1,24 +1,84 @@ +import logging + from django.contrib.contenttypes.models import ContentType -from nbxsync.models import ZabbixServerAssignment +from nbxsync.models import ZabbixHostBinding, ZabbixServerAssignment +from nbxsync.utils import get_assigned_zabbixobjects +from nbxsync.utils.host_binding import HostBindingDeleteProxy, iter_host_bindings from nbxsync.utils.sync import HostSync from nbxsync.utils.sync.safe_delete import safe_delete +logger = logging.getLogger(__name__) + __all__ = ('DeleteHostJob',) class DeleteHostJob: def __init__(self, **kwargs): - self.instance = kwargs.get('instance') # This is the Device or VirtualMachine object + self.binding_ids = tuple(kwargs.get('binding_ids') or ()) + self.instance = kwargs.get('instance') + + def _bindings(self): + if self.binding_ids: + return ZabbixHostBinding.objects.filter(pk__in=self.binding_ids).select_related( + 'zabbixserver', + 'assigned_object_type', + ) + if self.instance is not None: + return iter_host_bindings(self.instance) + return ZabbixHostBinding.objects.none() def run(self): - object_ct = ContentType.objects.get_for_model(self.instance) - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.instance.pk) + failures = [] + servers_seen = set() + + # Binding-based deletes intentionally omit sync_enabled checks. + # Deleting a Device/VM in NetBox is inventory retirement: the Zabbix + # host must go even if automatic sync was disabled on the assignment + # or server. The legacy path below still respects sync_enabled. + for binding in self._bindings(): + servers_seen.add(binding.zabbixserver_id) + assigned_object = self.instance if self.instance is not None else binding.assigned_object + proxy = HostBindingDeleteProxy(binding, assigned_object=assigned_object) + extra_args = {'all_objects': {'_instance': assigned_object}} if assigned_object is not None else None + try: + safe_delete(HostSync, proxy, **({'extra_args': extra_args} if extra_args else {})) + except Exception as exc: + failures.append((binding.pk, exc)) + logger.warning('Failed to delete bound host %s: %s', binding, exc) + + # Legacy compatibility: jobs queued before deletion signals captured + # binding IDs pass an instance here (no binding_ids). Fall back to + # resolving assignments from the instance directly. Safe to remove + # once no pre-binding-ID RQ jobs remain in-flight. + if self.instance is not None and not self.binding_ids: + self._delete_legacy_assignments(servers_seen, failures) - for assignment in zabbixserver_assignments: + if failures: + binding_ids = ', '.join(str(binding_id) for binding_id, _ in failures) + raise RuntimeError(f'Failed to delete Zabbix host bindings: {binding_ids}') + + def _delete_legacy_assignments(self, servers_seen, failures): + extra_args = {'all_objects': {'_instance': self.instance}} + try: + all_objects = get_assigned_zabbixobjects(self.instance) + server_assignments = all_objects.get('server_assignments', []) + except Exception: + logger.exception('Failed to resolve inherited assignments for %s; using direct assignments only.', self.instance) + instance_ct = ContentType.objects.get_for_model(self.instance) + server_assignments = ZabbixServerAssignment.objects.filter( + assigned_object_type=instance_ct, + assigned_object_id=self.instance.pk, + ).select_related('zabbixserver') + + for assignment in server_assignments: + if assignment.zabbixserver_id in servers_seen: + continue if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: continue - self.delete_host(assignment) - - def delete_host(self, assignment): - safe_delete(HostSync, assignment) + servers_seen.add(assignment.zabbixserver_id) + try: + safe_delete(HostSync, assignment, extra_args=extra_args) + except Exception as exc: + failures.append((f'legacy:{assignment.pk}', exc)) + logger.warning('Failed to delete host for %s via assignment %s: %s', self.instance, assignment, exc) diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 321bb80c..7f3e199e 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -1,11 +1,12 @@ +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.host_binding import HostBindingDeleteProxy, get_managed_host_id, iter_host_bindings from nbxsync.utils.sync import HostGroupSync, HostInterfaceSync, HostSync, ProxyGroupSync, ProxySync, run_zabbix_operation from nbxsync.utils.sync.safe_delete import safe_delete from nbxsync.utils.sync.safe_sync import safe_sync @@ -19,11 +20,56 @@ class SyncHostJob: def __init__(self, **kwargs): self.instance = kwargs.get('instance') # This is the Device or VirtualMachine object + self.partial_errors = [] + + 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 = getattr(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 def run(self): - object_ct = ContentType.objects.get_for_model(self.instance) + # Recoverable per-host errors are collected so that independent work + # (other interfaces, other Zabbix servers, binding retirement) still + # runs, while the job as a whole still reports the failure. + self.partial_errors = [] - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.instance.pk) + 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 +77,32 @@ def run(self): status_mapping = getattr(pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) + assigned_server_ids = {assignment.zabbixserver_id for assignment in zabbixserver_assignments} + + # Excluded objects must retire every active binding instead of merely + # skipping future synchronization. + if self._is_excluded(pluginsettings, all_objects): + for assignment in zabbixserver_assignments: + if assignment.sync_enabled and assignment.zabbixserver.sync_enabled: + assignment = self._prepare_assignment(assignment) + # Exclusion is an explicit operator decision, so it deletes + # unconditionally — unlike lost server assignments, which are + # gated on allow_inherited_deletion (see _retire_unassigned_bindings). + self.delete_host(assignment) + self._retire_unassigned_bindings(assigned_server_ids) + logger.info('Skipping sync for %s (excluded)', self.instance) + self._raise_on_partial_failure() + return for assignment in zabbixserver_assignments: + assigned_server_ids.add(assignment.zabbixserver_id) + 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,31 +110,133 @@ 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) + # Retire any durable bindings whose server assignment has disappeared. + # (This covers loss of all assignments, including inherited ones.) + self._retire_unassigned_bindings(assigned_server_ids) + + 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) + + self._raise_on_partial_failure() + + def _deletion_blocked(self, reason, zabbixserver, hostid): + """Report whether an inheritance-driven deletion may proceed. + + Deleting a Zabbix host discards its measurement history, and the + trigger can be as indirect as moving a Site into another SiteGroup. When + ``allow_inherited_deletion`` is off, the host is kept and the impact is + logged so operators can review it before enabling the setting. + """ + if get_plugin_settings().allow_inherited_deletion: + return False + logger.warning( + 'Not deleting Zabbix host for %s on %s (hostid %s): %s requires deletion, but allow_inherited_deletion is disabled. Enable it to let nbxsync remove the host and its history.', + self.instance, + zabbixserver, + hostid or 'unknown', + reason, + ) + return True + + def _record_partial_failure(self, assignment, message): + """Remember a recoverable failure and surface it on the assignment row.""" + self.partial_errors.append(message) + logger.warning('%s: %s', self.instance, message) + if assignment is not None: + assignment.update_sync_info(success=False, message=message[:3000]) + + def _raise_on_partial_failure(self): + """Fail the job when independent work completed but something was lost. + + Without this, an operator sees a successful reconciliation for a host + whose interfaces or template linkage never made it into Zabbix. + """ + errors = getattr(self, 'partial_errors', []) + if not errors: + return + summary = '; '.join(errors[:10]) + if len(errors) > 10: + summary = f'{summary}; (+{len(errors) - 10} more)' + raise RuntimeError(f'Partial sync failure for {self.instance}: {summary}') + + def _retire_unassigned_bindings(self, assigned_server_ids): + for binding in iter_host_bindings(self.instance): + if binding.zabbixserver_id in assigned_server_ids: + continue + if not binding.zabbixserver.sync_enabled: + continue + if self._deletion_blocked('no remaining Zabbix server assignment', binding.zabbixserver, binding.hostid): + continue + proxy = HostBindingDeleteProxy(binding, assigned_object=self.instance) + try: + safe_delete(HostSync, proxy, extra_args={'all_objects': {'_instance': self.instance}}) + except Exception as e: + self._record_partial_failure(None, f'Failed to retire binding {binding}: {e}') def delete_host(self, assignment): - safe_delete(HostSync, assignment) + safe_delete(HostSync, assignment, extra_args={'all_objects': {'_instance': self.instance}}) - def verify_hostinterfaces(self, assignment): + def _resolve_all_objects(self, assignment): + """Resolve the assignments for one Zabbix server, OOB filtering included. + + Every caller must see the same interface set: an interface that sync_host + skips but verify_hostinterfaces still considers unexpected would be + deleted from Zabbix on every run and recreated on the next one. + """ all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) + all_objects['_instance'] = self.instance + + # use_oob_ip interfaces cannot be synced when the object has no OOB IP: + # a VM never has one, and a device may not have one yet. Syncing them + # anyway would link SNMP templates to a host without an SNMP interface. + has_oob_ip = bool(getattr(self.instance, 'oob_ip', None)) + unresolvable = [hi for hi in all_objects['hostinterfaces'] if getattr(hi, 'use_oob_ip', False) and not has_oob_ip] + if unresolvable: + all_objects['hostinterfaces'] = [hi for hi in all_objects['hostinterfaces'] if hi not in unresolvable] + if get_plugin_settings().allow_inherited_deletion: + logger.warning( + 'Skipping %s OOB interface(s) for %s: no out-of-band IP. Any interface already present in Zabbix will be removed because allow_inherited_deletion is enabled.', + len(unresolvable), + self.instance, + ) + else: + # Keep whatever Zabbix already has: an OOB IP that disappeared + # from NetBox is usually a data-entry gap, not an instruction to + # discard the interface and its item history. + all_objects['retained_hostinterfaces'] = unresolvable + logger.warning( + 'Skipping %s OOB interface(s) for %s: no out-of-band IP. Existing Zabbix interfaces are retained; enable allow_inherited_deletion to let nbxsync remove them.', + len(unresolvable), + self.instance, + ) + + return all_objects + + def verify_hostinterfaces(self, assignment): + all_objects = self._resolve_all_objects(assignment) 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 = self._resolve_all_objects(assignment) run_zabbix_operation(HostSync, assignment, 'check_default_hostinterface', extra_args={'all_objects': all_objects}) def sync_host(self, assignment): try: - all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) + all_objects = self._resolve_all_objects(assignment) # 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 + # Create all hostgroups (skip template-based assignments — they are + # created on-demand during HostSync.get_groups() with the actual + # device as render context) for hostgroup in all_objects['hostgroups']: + if hasattr(hostgroup, 'is_template') and hostgroup.is_template(): + continue safe_sync(HostGroupSync, hostgroup) # Sync ProxyGroups and proxies (in that order!) @@ -88,18 +258,52 @@ 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: # - interface_type (defaults should be synced first) # - type (group snmp, agent, jmx, etc) - # - id - hostinterfaces_sorted = sorted(all_objects['hostinterfaces'], key=lambda hostinterface: (-int(hostinterface.interface_type == 1), hostinterface.type, hostinterface.id)) + # - id (None-safe for transient ConfigGroup / hierarchy copies) + hostid = get_managed_host_id(self.instance, assignment.zabbixserver) or assignment.hostid + hostinterfaces_sorted = sorted( + all_objects['hostinterfaces'], + key=lambda hostinterface: (-int(hostinterface.interface_type == 1), hostinterface.type, hostinterface.id or 0), + ) for hostinterface in hostinterfaces_sorted: - safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': assignment.hostid}) + try: + safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': hostid, '_instance': self.instance}) + except RuntimeError 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. The failure is still reported at the + # end of the job so it cannot pass as a successful sync. + self._record_partial_failure(assignment, f'HostInterfaceSync failed for interface {hostinterface}: {e}') + + # Reconcile main flags after individual IF updates. A main-flag flip + # can leave two defaults briefly when HostInterfaceSync updates one + # interface at a time; check_default_hostinterface applies the + # atomic host.update repair. Safe when NetBox has no default for a + # type that still exists remotely (guarded inside check_default). + try: + run_zabbix_operation( + HostSync, + assignment, + 'check_default_hostinterface', + extra_args={'all_objects': all_objects}, + ) + except Exception as e: + self._record_partial_failure(assignment, f'check_default_hostinterface failed: {e}') - safe_sync(HostSync, assignment, extra_args={'all_objects': all_objects}) + # Final HostSync to link templates etc — a template conflict here + # (e.g. "Cannot inherit item with key snmptrap.fallback") must not + # abort the remaining work, but it is a real failure to report. + try: + safe_sync(HostSync, assignment, extra_args={'all_objects': all_objects}) + except Exception as e: + self._record_partial_failure(assignment, f'Final HostSync failed: {e}') except Exception as e: raise RuntimeError(f'Unexpected error: {e}') 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/migrations/0015_zabbixtemplaterule.py b/nbxsync/migrations/0015_zabbixtemplaterule.py new file mode 100644 index 00000000..27692d90 --- /dev/null +++ b/nbxsync/migrations/0015_zabbixtemplaterule.py @@ -0,0 +1,66 @@ +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + """ZabbixTemplateRule with compound criteria and optional hostgroup/tag (squashed).""" + + dependencies = [ + ('extras', '0122_charfield_null_choices'), + ('nbxsync', '0014_tag_assignment_targets'), + ] + + operations = [ + migrations.CreateModel( + name='ZabbixTemplateRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('pattern', models.CharField(max_length=500)), + ('role_pattern', models.CharField(blank=True, max_length=500)), + ('require_tags', models.CharField(blank=True, max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('priority', models.IntegerField(default=100)), + ( + 'manufacturer', + models.ForeignKey( + blank=True, + help_text='Optional. When set, the Device device_type.manufacturer must match. Empty = any manufacturer. Objects without a manufacturer (e.g. VMs) fail closed when this is set. PROTECT prevents deleting a Manufacturer that would silently widen matching rules.', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='zabbixtemplaterules', + to='dcim.manufacturer', + ), + ), + ('zabbixtemplate', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='zabbixtemplaterules', to='nbxsync.zabbixtemplate')), + ( + 'zabbixhostgroup', + models.ForeignKey( + blank=True, + help_text='Optional hostgroup assigned when the rule matches. PROTECT prevents deleting a hostgroup that would silently drop this side effect from matching rules.', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='zabbixtemplaterules', + to='nbxsync.zabbixhostgroup', + ), + ), + ( + 'zabbixtag', + models.ForeignKey(blank=True, help_text='Optional tag assigned when the rule matches. PROTECT prevents deleting a tag that would silently drop this side effect from matching rules.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='zabbixtemplaterules', to='nbxsync.zabbixtag'), + ), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'Zabbix Template Rule', + 'verbose_name_plural': 'Zabbix Template Rules', + 'ordering': ('priority', 'name'), + }, + ), + ] diff --git a/nbxsync/migrations/0016_zabbixhostbinding.py b/nbxsync/migrations/0016_zabbixhostbinding.py new file mode 100644 index 00000000..6870f124 --- /dev/null +++ b/nbxsync/migrations/0016_zabbixhostbinding.py @@ -0,0 +1,55 @@ +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.json + +import nbxsync.models.zabbixhostbinding + + +class Migration(migrations.Migration): + """ZabbixHostBinding plus hostid PositiveBigInteger alignment on assignments.""" + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0122_charfield_null_choices'), + ('nbxsync', '0015_zabbixtemplaterule'), + ] + + operations = [ + migrations.CreateModel( + name='ZabbixHostBinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('hostid', models.PositiveBigIntegerField()), + ('hostname', models.CharField(blank=True, max_length=255)), + ('assigned_object_id', models.PositiveBigIntegerField()), + ('assigned_object_type', models.ForeignKey(limit_choices_to=nbxsync.models.zabbixhostbinding._limit_assigned_objects, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('zabbixserver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_bindings', to='nbxsync.zabbixserver')), + ], + options={ + 'verbose_name': 'Zabbix Host Binding', + 'verbose_name_plural': 'Zabbix Host Bindings', + 'ordering': ('-created',), + }, + bases=(models.Model,), + ), + migrations.AddConstraint( + model_name='zabbixhostbinding', + constraint=models.UniqueConstraint(fields=('zabbixserver', 'assigned_object_type', 'assigned_object_id'), name='nbxsync_zabbixhostbinding_unique_binding_per_object', violation_error_message='A host can only be bound once to a given object on a Zabbix server.'), + ), + migrations.AddConstraint( + model_name='zabbixhostbinding', + constraint=models.UniqueConstraint(fields=('zabbixserver', 'hostid'), name='nbxsync_zabbixhostbinding_unique_hostid_per_server', violation_error_message='The same Zabbix host ID cannot be bound to multiple objects on a server.'), + ), + # Align assignment hostid storage with ZabbixHostBinding.hostid (PositiveBigInteger). + migrations.AlterField( + model_name='zabbixserverassignment', + name='hostid', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/nbxsync/models/__init__.py b/nbxsync/models/__init__.py index b18beadc..c03b4af5 100644 --- a/nbxsync/models/__init__.py +++ b/nbxsync/models/__init__.py @@ -1,5 +1,6 @@ -from .sync_info import * +# ruff: noqa: I001 +from .sync_info import * from .zabbixhostgroup import * from .zabbixserver import * from .zabbixtag import * @@ -8,6 +9,8 @@ from .zabbixconfigurationgroup import * from .zabbixconfigurationgroupassignment import * +from .zabbixhostbinding import * + from .zabbixmacro import * from .zabbixhostinterface import * from .zabbixproxygroup import * @@ -26,3 +29,4 @@ from .zabbixproblem import * from .zabbixevent import * +from .zabbixtemplaterule import * 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/zabbixhostbinding.py b/nbxsync/models/zabbixhostbinding.py new file mode 100644 index 00000000..4b2704de --- /dev/null +++ b/nbxsync/models/zabbixhostbinding.py @@ -0,0 +1,69 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from netbox.models import NetBoxModel + +__all__ = ('ZabbixHostBinding',) + + +def _limit_assigned_objects(): + """Lazy import for ``limit_choices_to`` to avoid model/constants cycles.""" + from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS + + return DEVICE_OR_VM_ASSIGNMENT_MODELS + + +class ZabbixHostBinding(NetBoxModel): + """Durable mapping from a NetBox Device/VM/VDC to a Zabbix host ID. + + Internal sync-identity record — not a user-facing NetBox object. There is + intentionally no form/table/view/API surface; bindings are created and + removed only by the host sync / delete path. + + This model is the source of truth for host identity during sync: + it survives inherited assignments (which are transient copies), renames, + and device deletion so the matching Zabbix host can always be found by + hostid rather than by hostname. + """ + + zabbixserver = models.ForeignKey( + to='nbxsync.ZabbixServer', + on_delete=models.CASCADE, + related_name='host_bindings', + ) + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=_limit_assigned_objects, + on_delete=models.CASCADE, + related_name='+', + ) + assigned_object_id = models.PositiveBigIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id', + ) + + hostid = models.PositiveBigIntegerField() + hostname = models.CharField(max_length=255, blank=True) + + class Meta: + verbose_name = 'Zabbix Host Binding' + verbose_name_plural = 'Zabbix Host Bindings' + ordering = ('-created',) + + constraints = [ + models.UniqueConstraint( + fields=['zabbixserver', 'assigned_object_type', 'assigned_object_id'], + name='%(app_label)s_%(class)s_unique_binding_per_object', + violation_error_message='A host can only be bound once to a given object on a Zabbix server.', + ), + models.UniqueConstraint( + fields=['zabbixserver', 'hostid'], + name='%(app_label)s_%(class)s_unique_hostid_per_server', + violation_error_message='The same Zabbix host ID cannot be bound to multiple objects on a server.', + ), + ] + + def __str__(self): + return f'{self.assigned_object} -> hostid:{self.hostid}@{self.zabbixserver}' diff --git a/nbxsync/models/zabbixhostgroupassignment.py b/nbxsync/models/zabbixhostgroupassignment.py index 28368fc4..95dc519e 100644 --- a/nbxsync/models/zabbixhostgroupassignment.py +++ b/nbxsync/models/zabbixhostgroupassignment.py @@ -12,6 +12,7 @@ from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS from nbxsync.constants import TEMPLATE_PATTERN from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup +from nbxsync.jinja_context import related_template_context, wrap_assignment_object __all__ = ('ZabbixHostgroupAssignment',) @@ -48,12 +49,17 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def get_context(self, **extra_context): + # Default object is the assignment target in device-shaped form so + # templates like Roles/{{ object.role.name }} resolve on a DeviceRole. + # Sync passes object= via extra_context and wins on update. context = { - 'object': self.assigned_object, + 'object': wrap_assignment_object(self.assigned_object), 'value': self.zabbixhostgroup.value, 'name': self.zabbixhostgroup.name, } context.update(extra_context) + # Aliases follow the final render object (host during sync). + context.update(related_template_context(context.get('object'))) return context def render(self, **context): 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/models/zabbixserverassignment.py b/nbxsync/models/zabbixserverassignment.py index 8ba14ee8..128ee2b4 100644 --- a/nbxsync/models/zabbixserverassignment.py +++ b/nbxsync/models/zabbixserverassignment.py @@ -13,7 +13,7 @@ class ZabbixServerAssignment(SyncInfoModel, NetBoxModel): zabbixserver = models.ForeignKey('nbxsync.ZabbixServer', on_delete=models.CASCADE, related_name='zabbixserverassignment') - hostid = models.IntegerField(blank=True, null=True) + hostid = models.PositiveBigIntegerField(blank=True, null=True) zabbixproxy = models.ForeignKey(to='nbxsync.ZabbixProxy', blank=True, null=True, on_delete=models.CASCADE) zabbixproxygroup = models.ForeignKey(to='nbxsync.ZabbixProxyGroup', blank=True, null=True, on_delete=models.CASCADE) assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=ASSIGNMENT_MODELS, on_delete=models.CASCADE, related_name='+', blank=True, null=True) diff --git a/nbxsync/models/zabbixtagassignment.py b/nbxsync/models/zabbixtagassignment.py index f9548764..c13437e6 100644 --- a/nbxsync/models/zabbixtagassignment.py +++ b/nbxsync/models/zabbixtagassignment.py @@ -12,6 +12,7 @@ from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS from nbxsync.constants import TEMPLATE_PATTERN from nbxsync.models import ZabbixConfigurationGroup +from nbxsync.jinja_context import related_template_context, wrap_assignment_object __all__ = ('ZabbixTagAssignment',) @@ -78,13 +79,15 @@ def save(self, *args, **kwargs): def get_context(self, **extra_context): context = { - 'object': self.assigned_object, + 'object': wrap_assignment_object(self.assigned_object), 'tag': self.zabbixtag.tag, 'value': self.zabbixtag.value, 'name': self.zabbixtag.name, 'description': self.zabbixtag.description, } context.update(extra_context) + # Aliases follow the final render object (host during sync). + context.update(related_template_context(context.get('object'))) return context def __str__(self): diff --git a/nbxsync/models/zabbixtemplaterule.py b/nbxsync/models/zabbixtemplaterule.py new file mode 100644 index 00000000..2fa774c3 --- /dev/null +++ b/nbxsync/models/zabbixtemplaterule.py @@ -0,0 +1,159 @@ +import logging +import re +from functools import lru_cache + +from django.core.exceptions import ValidationError +from django.db import models + +from netbox.models import NetBoxModel + +logger = logging.getLogger(__name__) + +__all__ = ('ZabbixTemplateRule',) + +# Platform names are short. Keeping the cap low bounds the cost of a careless +# pattern without touching process-global signals (which break RQ job timeouts). +_MAX_MATCH_INPUT = 64 +# Role names are capped by NetBox at 100 characters. +_MAX_ROLE_INPUT = 100 + +# NetBox tag slugs: lowercase alphanumerics, dash, underscore. +_TAG_SLUG = re.compile(r'^[a-z0-9_-]+$') +# Nested quantifiers like (a+)+ / (a*){2,} are the classic ReDoS shape. +_NESTED_QUANTIFIER = re.compile(r'(? _MAX_MATCH_INPUT: + logger.warning('Rule "%s" not evaluated: platform name exceeds %s characters', self.name, _MAX_MATCH_INPUT) + return False + try: + if not _compiled_pattern(self.pattern).search(platform_name): + return False + except re.error as err: + # Patterns are validated on save, but a rule may predate that + # validation or have been written directly to the database. + logger.error('Rule "%s" has an invalid pattern "%s": %s', self.name, self.pattern, err) + return False + + if self.role_pattern: + role_value = role_name or '' + if len(role_value) > _MAX_ROLE_INPUT: + logger.warning('Rule "%s" not evaluated: role name exceeds %s characters', self.name, _MAX_ROLE_INPUT) + return False + try: + if not _compiled_pattern(self.role_pattern).search(role_value): + return False + except re.error as err: + logger.error('Rule "%s" has an invalid role pattern "%s": %s', self.name, self.role_pattern, err) + return False + + required = self.required_tag_slugs() + if required: + tags = set(netbox_tags or ()) + if not all(slug in tags for slug in required): + return False + + if self.manufacturer_id is not None: + # Fail closed: missing manufacturer (VMs, incomplete device_type) must + # not satisfy a vendor-scoped rule. Compare by PK only — never raise + # on a missing related object. + if manufacturer_id is None or manufacturer_id != self.manufacturer_id: + return False + + return True + + def __str__(self): + return f'{self.name} ({self.pattern})' diff --git a/nbxsync/navigation.py b/nbxsync/navigation.py index 108cbb2d..7db12f91 100644 --- a/nbxsync/navigation.py +++ b/nbxsync/navigation.py @@ -109,5 +109,17 @@ ), ), ), + PluginMenuItem( + link='plugins:nbxsync:zabbixtemplaterule_list', + link_text='Template Rules', + permissions=['nbxsync.view_zabbixtemplaterule'], + buttons=( + PluginMenuButton( + link='plugins:nbxsync:zabbixtemplaterule_add', + title='Add', + icon_class='mdi mdi-plus-thick', + ), + ), + ), ) menu = PluginMenu(label='Zabbix', groups=(('zabbix', items),), icon_class='mdi mdi-monitor-multiple') 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/nbobjects.py b/nbxsync/signals/nbobjects.py index 7610b2da..feeae08c 100644 --- a/nbxsync/signals/nbobjects.py +++ b/nbxsync/signals/nbobjects.py @@ -1,14 +1,17 @@ from django.contrib.contenttypes.models import ContentType +from django.db import transaction from django.db.models.signals import pre_delete from django.dispatch import receiver from django_rq import get_queue +from rq import Retry +from virtualization.models import VirtualMachine from dcim.models import Device, VirtualDeviceContext -from virtualization.models import VirtualMachine from nbxsync.choices.syncsot import SyncSOT -from nbxsync.models import ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixMacroAssignment, ZabbixServerAssignment, ZabbixTagAssignment, ZabbixTemplateAssignment +from nbxsync.models import ZabbixHostBinding, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixMacroAssignment, ZabbixServerAssignment, ZabbixTagAssignment, ZabbixTemplateAssignment from nbxsync.settings import get_plugin_settings +from nbxsync.utils.host_binding import set_host_binding __all__ = ('handle_deleted_object',) @@ -34,16 +37,47 @@ def handle_deleted_object(sender, instance, **kwargs): ZabbixMacroAssignment.objects.filter(assigned_object_type=instance_ct, assigned_object_id=instance.id).delete() host_sot = getattr(pluginsettings.sot, 'host', None) - # If the SOT is Netbox, delete the host from Netbox if host_sot == SyncSOT.NETBOX: - queue = get_queue('low') - queue.enqueue_job( - queue.create_job( - func='nbxsync.worker.deletehost', - args=[instance], - timeout=9000, + bindings = list( + ZabbixHostBinding.objects.filter( + assigned_object_type=instance_ct, + assigned_object_id=instance.pk, ) ) + bound_server_ids = {binding.zabbixserver_id for binding in bindings} + legacy_assignments = ZabbixServerAssignment.objects.filter( + assigned_object_type=instance_ct, + assigned_object_id=instance.pk, + hostid__isnull=False, + ).select_related('zabbixserver') + for assignment in legacy_assignments: + if assignment.zabbixserver_id in bound_server_ids: + continue + binding = set_host_binding( + instance, + assignment.zabbixserver, + int(assignment.hostid), + hostname=str(instance), + ) + bindings.append(binding) + bound_server_ids.add(assignment.zabbixserver_id) + + binding_ids = tuple(sorted(binding.pk for binding in bindings)) + + if binding_ids: + + def enqueue_delete(): + queue = get_queue('low') + queue.enqueue_job( + queue.create_job( + func='nbxsync.worker.deletehost', + args=[binding_ids], + timeout=9000, + retry=Retry(max=5, interval=[60, 300, 900, 3600, 21600]), + ) + ) + + transaction.on_commit(enqueue_delete) # If Zabbix is the SOT, dont delete it from Zabbix, but do delete the ServerAssignment if host_sot == SyncSOT.ZABBIX: diff --git a/nbxsync/systemjobs/sync_objects.py b/nbxsync/systemjobs/sync_objects.py index b872f32d..651e07e1 100644 --- a/nbxsync/systemjobs/sync_objects.py +++ b/nbxsync/systemjobs/sync_objects.py @@ -1,39 +1,210 @@ +import logging +from time import monotonic + +from django.contrib.contenttypes.models import ContentType from django_rq import get_queue +from rq.exceptions import NoSuchJobError +from rq.job import Job +from virtualization.models import Cluster, ClusterType, VirtualMachine +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup, VirtualDeviceContext from netbox.jobs import JobRunner, system_job -from nbxsync.models import ZabbixServerAssignment, ZabbixConfigurationGroup +from nbxsync.models import ZabbixConfigurationGroup, ZabbixHostBinding, ZabbixServerAssignment from nbxsync.settings import get_plugin_settings +logger = logging.getLogger(__name__) + +_ACTIVE_JOB_STATUSES = {'queued', 'started', 'deferred', 'scheduled'} +_DEVICE_CT = None +_VM_CT = None +_VDC_CT = None + def GetSyncInterval(): pluginsettings = get_plugin_settings() return pluginsettings.backgroundsync.objects.interval +def _device_ct(): + global _DEVICE_CT + if _DEVICE_CT is None: + _DEVICE_CT = ContentType.objects.get_for_model(Device) + return _DEVICE_CT + + +def _vm_ct(): + global _VM_CT + if _VM_CT is None: + _VM_CT = ContentType.objects.get_for_model(VirtualMachine) + return _VM_CT + + +def _vdc_ct(): + global _VDC_CT + if _VDC_CT is None: + _VDC_CT = ContentType.objects.get_for_model(VirtualDeviceContext) + return _VDC_CT + + +def _object_with_descendants_qs(obj, child_attr, manager): + """Return a queryset of objects matching *child_attr* on obj and its descendants.""" + if hasattr(obj, 'get_descendants'): + descendants = obj.get_descendants(include_self=True) + else: + descendants = type(obj).objects.filter(pk=obj.pk) + return manager.filter(**{f'{child_attr}__in': descendants}) + + +def _sync_job_id(app_label, model, object_id): + return f'nbxsync-host-{app_label}-{model}-{object_id}' + + +def _job_is_active(queue, job_id): + """True when a sync for this host is already queued or running. + + Terminal jobs (finished/failed/…) are removed so the deterministic job_id + can be reused. Failed jobs are logged before deletion so the failure is not + silently erased from operator-visible history. + """ + try: + job = Job.fetch(job_id, connection=queue.connection) + except NoSuchJobError: + return False + + status = job.get_status(refresh=True) + if status in _ACTIVE_JOB_STATUSES: + return True + + if status == 'failed': + logger.warning('Replacing failed sync job %s so reconciliation can retry it: %s', job_id, getattr(job, 'exc_info', None) or status) + + job.delete() + return False + + +def _add_device_vm_pks(keys, device_qs=None, vm_qs=None): + """Add (app_label, model, pk) tuples without materialising model instances.""" + if device_qs is not None: + ct = _device_ct() + for pk in device_qs.values_list('pk', flat=True).iterator(): + keys.add((ct.app_label, ct.model, pk)) + if vm_qs is not None: + ct = _vm_ct() + for pk in vm_qs.values_list('pk', flat=True).iterator(): + keys.add((ct.app_label, ct.model, pk)) + + +def _collect_eligible_keys(assignment, keys): # noqa: C901 + """Add Device/VM/VDC keys covered by a ZabbixServerAssignment.""" + obj = assignment.assigned_object + if obj is None: + return 0 + + model = type(obj) + before = len(keys) + + if model is Device: + ct = _device_ct() + keys.add((ct.app_label, ct.model, obj.pk)) + elif model is VirtualMachine: + ct = _vm_ct() + keys.add((ct.app_label, ct.model, obj.pk)) + elif model is VirtualDeviceContext: + ct = _vdc_ct() + keys.add((ct.app_label, ct.model, obj.pk)) + elif model is SiteGroup: + sites = _object_with_descendants_qs(obj, 'group', Site.objects) + _add_device_vm_pks(keys, Device.objects.filter(site__in=sites), VirtualMachine.objects.filter(site__in=sites)) + elif model is Site: + _add_device_vm_pks(keys, Device.objects.filter(site=obj), VirtualMachine.objects.filter(site=obj)) + elif model is Region: + sites = _object_with_descendants_qs(obj, 'region', Site.objects) + _add_device_vm_pks(keys, Device.objects.filter(site__in=sites), VirtualMachine.objects.filter(site__in=sites)) + elif model is DeviceRole: + roles = _object_with_descendants_qs(obj, 'pk', DeviceRole.objects) + _add_device_vm_pks(keys, Device.objects.filter(role__in=roles), VirtualMachine.objects.filter(role__in=roles)) + elif model is Platform: + _add_device_vm_pks(keys, Device.objects.filter(platform=obj), VirtualMachine.objects.filter(platform=obj)) + elif model is Manufacturer: + types = DeviceType.objects.filter(manufacturer=obj) + _add_device_vm_pks(keys, Device.objects.filter(device_type__in=types), VirtualMachine.objects.filter(platform__manufacturer=obj)) + elif model is DeviceType: + _add_device_vm_pks(keys, Device.objects.filter(device_type=obj)) + elif model is Cluster: + _add_device_vm_pks(keys, vm_qs=VirtualMachine.objects.filter(cluster=obj)) + elif model is ClusterType: + _add_device_vm_pks(keys, vm_qs=VirtualMachine.objects.filter(cluster__type=obj)) + + return len(keys) - before + + @system_job(interval=GetSyncInterval()) class SyncObjectsJob(JobRunner): class Meta: name = 'Zabbix Sync Hosts job' def run(self, *args, **kwargs): - synced_objects = [] - for obj in ZabbixServerAssignment.objects.all(): - # dont try to sync ZabbixConfigurationGroups - if isinstance(obj.assigned_object, ZabbixConfigurationGroup): + started_at = monotonic() + queue = None + keys = set() + assignments_inspected = 0 + bindings_inspected = 0 + hosts_resolved = 0 + active_jobs_skipped = 0 + jobs_enqueued = 0 + disabled_scopes = 0 + + for assignment in ZabbixServerAssignment.objects.all().select_related('zabbixserver'): + assignments_inspected += 1 + if isinstance(assignment.assigned_object, ZabbixConfigurationGroup): continue - if obj.assigned_object in synced_objects: + if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: + disabled_scopes += 1 + continue + + hosts_resolved += _collect_eligible_keys(assignment, keys) + + for binding in ZabbixHostBinding.objects.select_related('assigned_object_type').iterator(): + bindings_inspected += 1 + if not binding.assigned_object_type_id or not binding.assigned_object_id: + continue + # Orphan bindings (NetBox object already gone) must not enqueue sync jobs. + model_class = binding.assigned_object_type.model_class() + if model_class is None or not model_class.objects.filter(pk=binding.assigned_object_id).exists(): + continue + keys.add((binding.assigned_object_type.app_label, binding.assigned_object_type.model, binding.assigned_object_id)) + + hosts_deduplicated = max(0, hosts_resolved + bindings_inspected - len(keys)) + + for app_label, model, pk in keys: + if queue is None: + queue = get_queue('low') + + job_id = _sync_job_id(app_label, model, pk) + if _job_is_active(queue, job_id): + active_jobs_skipped += 1 continue - else: - synced_objects.append(obj.assigned_object) - instance = obj.assigned_object - queue = get_queue('low') queue.enqueue_job( queue.create_job( func='nbxsync.worker.synchost', - args=[instance], + args=[app_label, model, pk], timeout=9000, + job_id=job_id, ) ) + jobs_enqueued += 1 + + logger.info( + 'Zabbix host reconciliation complete: assignments=%d bindings=%d resolved=%d deduplicated=%d active_skipped=%d enqueued=%d disabled=%d duration_seconds=%.3f', + assignments_inspected, + bindings_inspected, + hosts_resolved, + hosts_deduplicated, + active_jobs_skipped, + jobs_enqueued, + disabled_scopes, + monotonic() - started_at, + ) diff --git a/nbxsync/tables/__init__.py b/nbxsync/tables/__init__.py index e6092562..fe2f0739 100644 --- a/nbxsync/tables/__init__.py +++ b/nbxsync/tables/__init__.py @@ -23,3 +23,4 @@ # Zabbix Operational View from .zabbixproblem import * from .zabbixevent import * +from .zabbixtemplaterule import * diff --git a/nbxsync/tables/columns.py b/nbxsync/tables/columns.py index 4910f69b..a60b36fc 100644 --- a/nbxsync/tables/columns.py +++ b/nbxsync/tables/columns.py @@ -47,5 +47,11 @@ def __init__(self, *args, **kwargs): def render(self, value, record, table): instance = getattr(table, 'instance', None) - output, success = record.render(object=instance) - return output + try: + if instance is not None: + output, success = record.render(object=instance) + else: + output, success = record.render() + return output if success and output else '' + except Exception: + return '' diff --git a/nbxsync/tables/zabbixhostgroup.py b/nbxsync/tables/zabbixhostgroup.py index ad786053..8da46d4c 100644 --- a/nbxsync/tables/zabbixhostgroup.py +++ b/nbxsync/tables/zabbixhostgroup.py @@ -13,6 +13,22 @@ class ZabbixHostgroupTable(NetBoxTable): name = tables.Column(linkify=True) zabbixserver = tables.Column(linkify=True, verbose_name=_('Zabbix Server')) + assignment_count = tables.Column( + verbose_name=_('Assignments'), + empty_values=(), + orderable=False, + ) + rule_count = tables.Column( + verbose_name=_('Rules'), + empty_values=(), + orderable=False, + ) + + def render_assignment_count(self, record): + return getattr(record, 'assignment_count', '—') + + def render_rule_count(self, record): + return getattr(record, 'rule_count', '—') class Meta(NetBoxTable.Meta): model = ZabbixHostgroup @@ -23,14 +39,17 @@ class Meta(NetBoxTable.Meta): 'description', 'value', 'zabbixserver', + 'assignment_count', + 'rule_count', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', - 'description', 'value', + 'assignment_count', + 'rule_count', 'zabbixserver', ) @@ -46,7 +65,7 @@ class ZabbixHostgroupObjectViewTable(ZabbixInheritedAssignmentTable, NetBoxTable {% render_zabbix_hostgroup_assignment record as rendered_output %} {{ rendered_output|escape }} """, - verbose_name='Value', + verbose_name=_('Value'), ) class Meta(NetBoxTable.Meta): diff --git a/nbxsync/tables/zabbixhostgroupassignment.py b/nbxsync/tables/zabbixhostgroupassignment.py index f977cdd9..cee741db 100644 --- a/nbxsync/tables/zabbixhostgroupassignment.py +++ b/nbxsync/tables/zabbixhostgroupassignment.py @@ -24,7 +24,7 @@ class ZabbixHostgroupAssignmentTable(ZabbixInheritedAssignmentTable, NetBoxTable {% render_zabbix_hostgroup_assignment record as rendered_output %} {{ rendered_output|escape }} """, - verbose_name='Value', + verbose_name=_('Value'), ) class Meta(NetBoxTable.Meta): @@ -56,7 +56,7 @@ class ZabbixHostgroupAssignmentObjectViewTable(ZabbixInheritedAssignmentTable, N {% render_zabbix_hostgroup_assignment record as rendered_output %} {{ rendered_output|escape }} """, - verbose_name='Value', + verbose_name=_('Value'), ) class Meta(NetBoxTable.Meta): diff --git a/nbxsync/tables/zabbixserverassignment.py b/nbxsync/tables/zabbixserverassignment.py index b9a3bf56..95988026 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/tables/zabbixtagassignment.py b/nbxsync/tables/zabbixtagassignment.py index ed5e64e3..c2e53765 100644 --- a/nbxsync/tables/zabbixtagassignment.py +++ b/nbxsync/tables/zabbixtagassignment.py @@ -1,7 +1,7 @@ -import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from django_tables2.utils import A +import django_tables2 as tables +from django_tables2.utils import A from netbox.tables import NetBoxTable from nbxsync.models import ZabbixTagAssignment @@ -24,7 +24,7 @@ class ZabbixTagAssignmentTable(ZabbixInheritedAssignmentTable, NetBoxTable): {% render_zabbix_tag_assignment record as rendered_output %} {{ rendered_output|escape }} """, - verbose_name='Value', + verbose_name=_('Value'), ) class Meta(NetBoxTable.Meta): @@ -56,7 +56,7 @@ class ZabbixTagAssignmentObjectViewTable(ZabbixInheritedAssignmentTable, NetBoxT {% render_zabbix_tag_assignment record as rendered_output %} {{ rendered_output|escape }} """, - verbose_name='Value', + verbose_name=_('Value'), ) class Meta(NetBoxTable.Meta): diff --git a/nbxsync/tables/zabbixtemplaterule.py b/nbxsync/tables/zabbixtemplaterule.py new file mode 100644 index 00000000..9856f867 --- /dev/null +++ b/nbxsync/tables/zabbixtemplaterule.py @@ -0,0 +1,73 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ +from netbox.tables import NetBoxTable + +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleTable', 'ZabbixTemplateRuleHostgroupViewTable') + + +class ZabbixTemplateRuleTable(NetBoxTable): + name = tables.Column(linkify=True) + zabbixtemplate = tables.Column(linkify=True) + zabbixhostgroup = tables.Column(linkify=True) + zabbixtag = tables.Column(linkify=True) + manufacturer = tables.Column(linkify=True) + + class Meta(NetBoxTable.Meta): + model = ZabbixTemplateRule + fields = ( + 'pk', + 'name', + 'description', + 'pattern', + 'role_pattern', + 'require_tags', + 'manufacturer', + 'zabbixtemplate', + 'zabbixhostgroup', + 'zabbixtag', + 'enabled', + 'priority', + 'created', + 'last_updated', + ) + default_columns = ( + 'pk', + 'name', + 'pattern', + 'role_pattern', + 'manufacturer', + 'zabbixtemplate', + 'zabbixhostgroup', + 'enabled', + 'priority', + ) + + +class ZabbixTemplateRuleHostgroupViewTable(NetBoxTable): + """Rules that attach this hostgroup — embedded on the hostgroup detail page.""" + + name = tables.Column(linkify=True) + pattern = tables.Column() + zabbixtemplate = tables.Column(linkify=True, verbose_name=_('Template')) + priority = tables.Column() + enabled = tables.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = ZabbixTemplateRule + fields = ( + 'pk', + 'name', + 'pattern', + 'zabbixtemplate', + 'priority', + 'enabled', + ) + default_columns = ( + 'name', + 'pattern', + 'zabbixtemplate', + 'priority', + 'enabled', + ) 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/templates/nbxsync/zabbixhostgroup.html b/nbxsync/templates/nbxsync/zabbixhostgroup.html index cd89c75e..83e07509 100644 --- a/nbxsync/templates/nbxsync/zabbixhostgroup.html +++ b/nbxsync/templates/nbxsync/zabbixhostgroup.html @@ -49,10 +49,32 @@

{% if hostgroupassignment_table %} {% render_table hostgroupassignment_table %} {% else %} -
No objects assigned.
+
No objects assigned
{% endif %}

{% plugin_right_page object %}
+
+
+
+

+ Template rules + {% if perms.nbxsync.add_zabbixtemplaterule %} + + {% endif %} +

+ {% if templaterule_table %} + {% render_table templaterule_table %} + {% else %} +
No template rules
+ {% endif %} +
+
+
{% endblock %} diff --git a/nbxsync/templates/nbxsync/zabbixhostgroupassignment.html b/nbxsync/templates/nbxsync/zabbixhostgroupassignment.html index 27574a63..ea2aadd4 100644 --- a/nbxsync/templates/nbxsync/zabbixhostgroupassignment.html +++ b/nbxsync/templates/nbxsync/zabbixhostgroupassignment.html @@ -38,7 +38,9 @@
Zabbix Hostgroup
{% render_zabbix_hostgroup_assignment object as rendered_output %}
Rendered Value
-
{{ rendered_output|safe }}
+
+ {{ rendered_output|safe }} +
{% plugin_right_page object %} diff --git a/nbxsync/templates/nbxsync/zabbixtagassignment.html b/nbxsync/templates/nbxsync/zabbixtagassignment.html index b4c08cb4..2d041717 100644 --- a/nbxsync/templates/nbxsync/zabbixtagassignment.html +++ b/nbxsync/templates/nbxsync/zabbixtagassignment.html @@ -42,7 +42,9 @@
Zabbix Tag
{% render_zabbix_tag_assignment object as rendered_output %}
Rendered Value
-
{{ rendered_output|safe }}
+
+ {{ rendered_output|safe }} +
{% plugin_right_page object %} diff --git a/nbxsync/templates/nbxsync/zabbixtemplaterule.html b/nbxsync/templates/nbxsync/zabbixtemplaterule.html new file mode 100644 index 00000000..d8f37d88 --- /dev/null +++ b/nbxsync/templates/nbxsync/zabbixtemplaterule.html @@ -0,0 +1,72 @@ +{% extends template_extends|default:'generic/object.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load perms %} +{% load static %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% block content %} +
+
+
+
Zabbix Template Rule
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Pattern{{ object.pattern }}
Role Pattern{% if object.role_pattern %}{{ object.role_pattern }}{% else %}{{ ''|placeholder }}{% endif %}
Require Tags{% if object.require_tags %}{{ object.require_tags }}{% else %}{{ ''|placeholder }}{% endif %}
Manufacturer{{ object.manufacturer|linkify|placeholder }}
Zabbix Template{{ object.zabbixtemplate|linkify }}
Hostgroup{{ object.zabbixhostgroup|linkify|placeholder }}
Tag{{ object.zabbixtag|linkify|placeholder }}
Priority{{ object.priority }}
Enabled{% checkmark object.enabled %}
+
+ {% plugin_left_page object %} +
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/nbxsync/templatetags/zabbix_hostgroups.py b/nbxsync/templatetags/zabbix_hostgroups.py index 67d9eae2..231f9583 100644 --- a/nbxsync/templatetags/zabbix_hostgroups.py +++ b/nbxsync/templatetags/zabbix_hostgroups.py @@ -1,9 +1,34 @@ from django import template + register = template.Library() -@register.simple_tag -def render_zabbix_hostgroup_assignment(assignment, **context): - output, success = assignment.render(**context) - return output +@register.simple_tag(takes_context=True) +def render_zabbix_hostgroup_assignment(context, assignment, **extra): + """Render a ZabbixHostgroupAssignment value for display. + + Sync passes ``object=`` explicitly. Without that override, + ``assignment.render`` uses the assignment target in device-shaped form + (see ``wrap_assignment_object``). When the target cannot satisfy the + template (e.g. ``Roles/{{ object.role.name }}`` on a SiteGroup), show the + raw template string — never a sample from an unrelated descendant device. + """ + if 'object' not in extra: + request_object = context.get('object') + if request_object is not None and _is_device_like(request_object): + extra['object'] = request_object + + output, success = assignment.render(**extra) + if success: + return output + if assignment.is_template(): + return assignment.zabbixhostgroup.value + return '' + + +def _is_device_like(obj) -> bool: + from dcim.models import Device, VirtualDeviceContext + from virtualization.models import VirtualMachine + + return isinstance(obj, Device | VirtualMachine | VirtualDeviceContext) diff --git a/nbxsync/templatetags/zabbix_tags.py b/nbxsync/templatetags/zabbix_tags.py index 6acf624b..e47ec233 100644 --- a/nbxsync/templatetags/zabbix_tags.py +++ b/nbxsync/templatetags/zabbix_tags.py @@ -1,9 +1,31 @@ from django import template + register = template.Library() -@register.simple_tag -def render_zabbix_tag_assignment(assignment, **context): - output, success = assignment.render(**context) - return output +@register.simple_tag(takes_context=True) +def render_zabbix_tag_assignment(context, assignment, **extra): + """Render a ZabbixTagAssignment value for display. + + Mirrors ``render_zabbix_hostgroup_assignment``: assignment-target context + with device-shaped wrapping; raw template on unresolvable hierarchy targets. + """ + if 'object' not in extra: + request_object = context.get('object') + if request_object is not None and _is_device_like(request_object): + extra['object'] = request_object + + output, success = assignment.render(**extra) + if success: + return output + if assignment.is_template(): + return assignment.zabbixtag.value + return '' + + +def _is_device_like(obj) -> bool: + from dcim.models import Device, VirtualDeviceContext + from virtualization.models import VirtualMachine + + return isinstance(obj, Device | VirtualMachine | VirtualDeviceContext) diff --git a/nbxsync/tests/api/test_zabbixtemplaterule.py b/nbxsync/tests/api/test_zabbixtemplaterule.py new file mode 100644 index 00000000..60599293 --- /dev/null +++ b/nbxsync/tests/api/test_zabbixtemplaterule.py @@ -0,0 +1,55 @@ +from django.urls import reverse + +from utilities.testing import APIViewTestCases + +from nbxsync.models import ZabbixServer, ZabbixTemplate, ZabbixTemplateRule + + +class ZabbixTemplateRuleAPITestCase( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.UpdateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase, +): + model = ZabbixTemplateRule + view_namespace = 'plugins-api:nbxsync' + brief_fields = ['display', 'enabled', 'id', 'name', 'pattern', 'url'] + + @classmethod + def setUpTestData(cls): + server = ZabbixServer.objects.create(name='Rule API Server', url='http://zabbix.local', token='abc123', validate_certs=True) + template = ZabbixTemplate.objects.create(name='Windows by Zabbix agent', zabbixserver=server, templateid=10081) + + ZabbixTemplateRule.objects.bulk_create( + [ + ZabbixTemplateRule(name='Windows', pattern='Windows', zabbixtemplate=template), + ZabbixTemplateRule(name='Linux', pattern='Ubuntu|Debian', zabbixtemplate=template), + ZabbixTemplateRule(name='Network', pattern='IOS|JunOS', zabbixtemplate=template), + ] + ) + + cls.create_data = [ + {'name': 'API Rule 1', 'pattern': 'Windows Server 2019', 'zabbixtemplate': template.pk}, + {'name': 'API Rule 2', 'pattern': 'Windows Server 2022', 'zabbixtemplate': template.pk, 'priority': 10}, + {'name': 'API Rule 3', 'pattern': 'RHEL [89]', 'zabbixtemplate': template.pk, 'enabled': False}, + ] + + cls.bulk_update_data = { + 'enabled': False, + } + + def test_invalid_pattern_is_rejected(self): + self.add_permissions('nbxsync.add_zabbixtemplaterule') + template = ZabbixTemplate.objects.first() + + response = self.client.post( + reverse('plugins-api:nbxsync-api:zabbixtemplaterule-list'), + {'name': 'Broken', 'pattern': 'Windows (', 'zabbixtemplate': template.pk}, + format='json', + **self.header, + ) + + self.assertHttpStatus(response, 400) + self.assertIn('pattern', response.data) + self.assertFalse(ZabbixTemplateRule.objects.filter(name='Broken').exists()) diff --git a/nbxsync/tests/jobs/test_deletehost.py b/nbxsync/tests/jobs/test_deletehost.py index 0c1df5dd..7324e2b3 100644 --- a/nbxsync/tests/jobs/test_deletehost.py +++ b/nbxsync/tests/jobs/test_deletehost.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -40,7 +40,11 @@ def test_run_calls_delete_host_for_each_assignment(self, mock_safe_delete): job = DeleteHostJob(instance=self.device) job.run() - mock_safe_delete.assert_called_once_with(HostSync, self.zabbixserverassignment) + mock_safe_delete.assert_called_once_with( + HostSync, + self.zabbixserverassignment, + extra_args={'all_objects': {'_instance': self.device}}, + ) @patch('nbxsync.jobs.deletehost.safe_delete') def test_run_skips_when_assignment_sync_disabled(self, mock_safe_delete): @@ -52,6 +56,26 @@ def test_run_skips_when_assignment_sync_disabled(self, mock_safe_delete): mock_safe_delete.assert_not_called() + @patch('nbxsync.jobs.deletehost.safe_delete') + def test_run_continues_after_disabled_assignment(self, mock_safe_delete): + self.zabbixserverassignment.sync_enabled = False + self.zabbixserverassignment.save() + server2 = ZabbixServer.objects.create(name='Zabbix2', url='http://z2.local', token='token2') + enabled_assignment = ZabbixServerAssignment.objects.create( + zabbixserver=server2, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid='67890', + ) + + DeleteHostJob(instance=self.device).run() + + mock_safe_delete.assert_called_once_with( + HostSync, + enabled_assignment, + extra_args={'all_objects': {'_instance': self.device}}, + ) + @patch('nbxsync.jobs.deletehost.safe_delete') def test_run_skips_when_zabbixserver_sync_disabled(self, mock_safe_delete): self.zabbixserver.sync_enabled = False diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index ad55df94..06d0e835 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -1,10 +1,11 @@ +from types import SimpleNamespace 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 dcim.models import Device, Site from utilities.testing import create_test_device from nbxsync.choices import ZabbixProxyTypeChoices, ZabbixTLSChoices @@ -134,6 +135,101 @@ def test_run_swallows_trigger_dependency_sync_exception(self, mock_sync_dependen log_text = '\n'.join(log_ctx.output) self.assertIn('Zabbix API timeout', log_text) + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_skips_trigger_dependency_sync_when_deleted(self, mock_sync_dependencies): + get_plugin_settings().trigger_dependencies.enabled = True + self.device.status = 'decommissioning' + self.device.save() + get_plugin_settings().statusmapping.device['decommissioning'] = ZabbixHostStatus.DELETED + + job = SyncHostJob(instance=self.device) + job.run() + + mock_sync_dependencies.assert_not_called() + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + @patch.object(SyncHostJob, 'check_default_hostinterface') + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_skips_trigger_dependency_sync_for_vm(self, mock_sync_dependencies, _mock_check, _mock_verify, _mock_safe_sync): + from virtualization.models import VirtualMachine + + from utilities.testing import create_test_virtualmachine + + vm = create_test_virtualmachine(name='SyncHostVMOnly') + vm_ct = ContentType.objects.get_for_model(VirtualMachine) + ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=vm_ct, + assigned_object_id=vm.id, + hostid='999', + zabbixproxy=self.proxy, + ) + get_plugin_settings().trigger_dependencies.enabled = True + + job = SyncHostJob(instance=vm) + job.run() + + mock_sync_dependencies.assert_not_called() + + @patch.object(SyncHostJob, '_is_excluded', return_value=True) + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_skips_trigger_dependency_sync_when_excluded(self, mock_sync_dependencies, _mock_excluded): + get_plugin_settings().trigger_dependencies.enabled = True + + job = SyncHostJob(instance=self.device) + job.run() + + mock_sync_dependencies.assert_not_called() + + @patch('nbxsync.jobs.synchost.get_plugin_settings') + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_skips_trigger_dependency_sync_when_config_missing(self, mock_sync_dependencies, mock_settings): + mock_settings.return_value = SimpleNamespace( + statusmapping=SimpleNamespace(device={'active': None}, virtualmachine={}), + exclude_tag='', + allow_inherited_deletion=False, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + mock_sync_dependencies.assert_not_called() + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + def test_interface_sync_uses_binding_hostid_after_assignment_cleared(self, mock_verify_interfaces, mock_safe_sync): + from nbxsync.models import ZabbixHostBinding + from nbxsync.utils.sync import HostInterfaceSync + + self.zabbixserverassignment.hostid = None + self.zabbixserverassignment.save() + ZabbixHostBinding.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=4242, + hostname=self.device.name, + ) + + job = SyncHostJob(instance=self.device) + with patch('nbxsync.jobs.synchost.get_assigned_zabbixobjects') as mock_gao: + mock_gao.return_value = { + 'hostgroups': [], + 'hostinterfaces': [self.hostinterface], + 'server_assignments': [self.zabbixserverassignment], + 'templates': [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, + } + job.run() + + interface_calls = [call for call in mock_safe_sync.call_args_list if call.args and call.args[0] is HostInterfaceSync] + self.assertTrue(interface_calls) + self.assertEqual(interface_calls[0].kwargs['extra_args']['hostid'], 4242) + def test_run_sync_host_deleted(self): self.device.status = 'decommissioning' self.device.save() @@ -200,6 +296,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 +342,302 @@ 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) + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + def test_interface_sync_typeerror_propagates(self, mock_verify_interfaces, mock_safe_sync): + """A TypeError from HostInterfaceSync propagates (programming errors are not swallowed).""" + call_log = {'hostiface_calls': 0} + + def side_effect(sync_class, *args, **kwargs): + name = getattr(sync_class, '__name__', None) + if name == 'HostInterfaceSync': + call_log['hostiface_calls'] += 1 + raise TypeError('bad argument') + return None + + mock_safe_sync.side_effect = side_effect + + job = SyncHostJob(instance=self.device) + + with patch('nbxsync.jobs.synchost.get_assigned_zabbixobjects') as mock_gao: + mock_gao.return_value = { + 'hostgroups': [], + 'hostinterfaces': [self.hostinterface], + 'server_assignments': [self.zabbixserverassignment], + 'templates': [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, + } + + with self.assertRaises(RuntimeError) as context: + job.run() + + self.assertIn('Unexpected error', str(context.exception)) + self.assertGreater(call_log['hostiface_calls'], 0) + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + def test_interface_sync_runtimeerror_continues_then_reports(self, mock_verify_interfaces, mock_safe_sync): + """A RuntimeError from HostInterfaceSync does not stop the remaining work, but the job still fails. + + Independent work (other interfaces, the final template linkage, binding + retirement) must complete, and the recoverable failure must be reported + as an aggregated error so a host with a missing interface cannot be + mistaken for a successful reconciliation. + """ + call_log = {'hostiface_calls': 0, 'hostsync_calls': 0} + + def side_effect(sync_class, *args, **kwargs): + name = getattr(sync_class, '__name__', None) + if name == 'HostInterfaceSync': + call_log['hostiface_calls'] += 1 + raise RuntimeError('Error syncing HostInterfaceSync: SNMP credentials wrong') + if name == 'HostSync': + call_log['hostsync_calls'] += 1 + return None + + mock_safe_sync.side_effect = side_effect + + job = SyncHostJob(instance=self.device) + + with patch('nbxsync.jobs.synchost.get_assigned_zabbixobjects') as mock_gao: + mock_gao.return_value = { + 'hostgroups': [], + 'hostinterfaces': [self.hostinterface], + 'server_assignments': [self.zabbixserverassignment], + 'templates': [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, + } + + with self.assertRaises(RuntimeError) as context: + job.run() + + self.assertIn('Partial sync failure', str(context.exception)) + self.assertIn('SNMP credentials wrong', str(context.exception)) + self.assertGreater(call_log['hostiface_calls'], 0) + # The final HostSync still ran after the interface failure. + self.assertGreaterEqual(call_log['hostsync_calls'], 2) + + +class SyncHostJobInheritedSNMPTests(TestCase): + """Job-level coverage for inherited Agent+SNMP when only Agent exists remotely.""" + + def setUp(self): + self.device = create_test_device(name='STA-ME05-job') + self.device_ct = ContentType.objects.get_for_model(Device) + self.zabbixserver = ZabbixServer.objects.create(name='Zabbix Job SNMP', url='http://zabbix.local', token='abc123') + self.ip = IPAddress.objects.create(address='10.0.109.60/24') + self.assignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + hostid='14030', + ) + self.agent = ZabbixHostInterface( + zabbixserver=self.zabbixserver, + type=1, + interface_type=1, + useip=1, + dns='', + ip=self.ip, + port=10050, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + self.agent.pk = None + self.agent.interfaceid = None + self.agent._is_inherited_copy = True + self.snmp = ZabbixHostInterface( + zabbixserver=self.zabbixserver, + type=2, + interface_type=1, + useip=1, + dns='', + ip=self.ip, + port=161, + snmp_version=2, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + self.snmp.pk = None + self.snmp.interfaceid = None + self.snmp._is_inherited_copy = True + + self.remote = [ + {'interfaceid': '3285', 'hostid': '14030', 'type': 1, 'main': 1, 'useip': 1, 'port': '10050', 'ip': '10.0.109.60', 'dns': ''}, + ] + self.creates = [] + self.hostsync_calls = 0 + + self.zabbix_patcher = patch('nbxsync.utils.sync.run_zabbix_operations.ZabbixConnection') + mock_conn_class = self.zabbix_patcher.start() + self.addCleanup(self.zabbix_patcher.stop) + self.mock_api = MagicMock() + self.mock_api.host.get.return_value = [{'hostid': '14030'}] + self.mock_api.host.create.return_value = {'hostids': ['14030']} + self.mock_api.host.update.return_value = {'hostids': ['14030']} + self.mock_api.host.delete.return_value = True + self.mock_api.hostinterface.get.side_effect = self._get_interfaces + self.mock_api.hostinterface.create.side_effect = self._create_interface + self.mock_api.hostgroup.get.return_value = [{'groupid': '1'}] + mock_conn_class.return_value.__enter__.return_value = self.mock_api + + def _get_interfaces(self, **kwargs): + ifaces = list(self.remote) + wanted = (kwargs.get('filter') or {}).get('type') + if wanted is not None: + ifaces = [iface for iface in ifaces if str(int(iface['type'])) == str(int(wanted))] + return ifaces + + def _create_interface(self, **params): + if int(params.get('type', 0)) == 2 and int(params.get('main', 1)) == 0: + raise RuntimeError('No default interface for "SNMP" type on "CH-STA-D-ME05".') + self.creates.append(params) + new_id = str(5000 + len(self.creates)) + self.remote.append( + { + 'interfaceid': new_id, + 'hostid': '14030', + 'type': int(params['type']), + 'main': int(params.get('main', 1)), + 'useip': int(params.get('useip', 1)), + 'port': str(params.get('port', '')), + 'ip': params.get('ip', ''), + 'dns': params.get('dns', ''), + } + ) + return {'interfaceids': [new_id]} + + def _assigned(self, templates=None): + return { + 'hostgroups': [], + 'hostinterfaces': [self.agent, self.snmp], + 'server_assignments': [self.assignment], + 'templates': templates or [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, + } + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + def test_no_snmp_template_still_creates_snmp_as_main(self, mock_verify, mock_safe_sync): + job = SyncHostJob(instance=self.device) + with patch('nbxsync.jobs.synchost.get_assigned_zabbixobjects', return_value=self._assigned()): + job.run() + + snmp_creates = [params for params in self.creates if int(params['type']) == 2] + self.assertEqual(len(snmp_creates), 1) + self.assertEqual(int(snmp_creates[0]['main']), 1) + + @patch.object(SyncHostJob, 'verify_hostinterfaces') + def test_snmp_required_template_recovers_after_interface_create(self, mock_verify): + from nbxsync.choices import HostInterfaceRequirementChoices + from nbxsync.models import ZabbixTemplate, ZabbixTemplateAssignment + + template = ZabbixTemplate.objects.create( + name='Needs SNMP', + zabbixserver=self.zabbixserver, + templateid=8801, + interface_requirements=[HostInterfaceRequirementChoices.SNMP], + ) + assignment = ZabbixTemplateAssignment(zabbixtemplate=template) + hostsync_calls = {'n': 0, 'failed_initial': False} + + def safe_sync_side_effect(sync_class, *args, **kwargs): + name = getattr(sync_class, '__name__', None) + if name == 'HostSync': + hostsync_calls['n'] += 1 + has_snmp = any(int(iface.get('type', 0)) == 2 and int(iface.get('main', 0)) == 1 for iface in self.remote) + if hostsync_calls['n'] == 1 and not has_snmp: + hostsync_calls['failed_initial'] = True + raise RuntimeError('Cannot link SNMP-required template without SNMP interface') + + job = SyncHostJob(instance=self.device) + with ( + patch('nbxsync.jobs.synchost.safe_sync', side_effect=safe_sync_side_effect), + patch('nbxsync.jobs.synchost.get_assigned_zabbixobjects', return_value=self._assigned(templates=[assignment])), + ): + job.run() + + snmp_creates = [params for params in self.creates if int(params['type']) == 2] + self.assertEqual(len(snmp_creates), 1) + self.assertEqual(int(snmp_creates[0]['main']), 1) + self.assertGreaterEqual(hostsync_calls['n'], 2) diff --git a/nbxsync/tests/jobs/test_synchost_exclusion.py b/nbxsync/tests/jobs/test_synchost_exclusion.py new file mode 100644 index 00000000..70f52df9 --- /dev/null +++ b/nbxsync/tests/jobs/test_synchost_exclusion.py @@ -0,0 +1,235 @@ +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_deletes_host_when_excluded_even_with_inherited_deletion_disabled(self, mock_safe_delete, mock_settings): + """Exclusion is an explicit operator decision and must delete the host + even when allow_inherited_deletion (the safety gate for lost server + assignments) is disabled. Regression test: the gate must not apply to + the exclusion path.""" + mock_settings.return_value.exclude_tag = 'do_not_monitor' + mock_settings.return_value.allow_inherited_deletion = False + 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_deletes_once_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_called_once() diff --git a/nbxsync/tests/models/test_templaterule_compound.py b/nbxsync/tests/models/test_templaterule_compound.py new file mode 100644 index 00000000..9242c67e --- /dev/null +++ b/nbxsync/tests/models/test_templaterule_compound.py @@ -0,0 +1,203 @@ +from django.db import models +from django.test import TestCase + +from dcim.models import DeviceRole, Manufacturer +from extras.models import Tag as NetBoxTag +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixHostgroup, ZabbixServer, ZabbixTemplate, ZabbixTemplateRule +from nbxsync.utils.inheritance import get_assigned_zabbixobjects + + +class TemplateRuleCompoundCriteriaTestCase(TestCase): + """Rule criteria are conjunctive: every configured criterion must match.""" + + def setUp(self): + self.server = ZabbixServer.objects.create(name='Criteria Zabbix', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Rule Template', zabbixserver=self.server, templateid=42) + self.tag = NetBoxTag.objects.create(name='critical', slug='critical') + self.dell = Manufacturer.objects.create(name='Dell', slug='dell-criteria') + self.hpe = Manufacturer.objects.create(name='HPE', slug='hpe-criteria') + self.device = create_test_device(name='CompoundDev') + + def _rule(self, pattern='.*', role_pattern='', require_tags='', manufacturer=None): + return ZabbixTemplateRule( + name='test-rule', + pattern=pattern, + zabbixtemplate=self.template, + role_pattern=role_pattern, + require_tags=require_tags, + manufacturer=manufacturer, + ) + + # --- matches() matrix --- + + def test_all_criteria_empty_matches_platform_only(self): + rule = self._rule(pattern='Ubuntu') + self.assertTrue(rule.matches('Ubuntu 22.04')) + self.assertFalse(rule.matches('Windows')) + + def test_backward_compatible_single_arg_signature(self): + rule = self._rule(pattern='Linux') + self.assertTrue(rule.matches('Arch Linux')) + + def test_role_pattern_constrains(self): + rule = self._rule(pattern='.*', role_pattern='^Switch') + self.assertTrue(rule.matches('anything', role_name='Switch Core')) + self.assertFalse(rule.matches('anything', role_name='Server')) + + def test_role_pattern_set_but_object_roleless_fails_closed(self): + rule = self._rule(pattern='.*', role_pattern='^Switch') + self.assertFalse(rule.matches('anything', role_name=None)) + + def test_require_tags_all_must_be_present(self): + rule = self._rule(pattern='.*', require_tags='critical,prod') + self.assertTrue(rule.matches('x', netbox_tags={'critical', 'prod', 'extra'})) + self.assertFalse(rule.matches('x', netbox_tags={'critical'})) + self.assertFalse(rule.matches('x', netbox_tags=None)) + + def test_manufacturer_match(self): + rule = self._rule(pattern='.*', manufacturer=self.dell) + self.assertTrue(rule.matches('x', manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('x', manufacturer_id=self.hpe.pk)) + + def test_manufacturer_set_but_object_without_manufacturer_fails_closed(self): + rule = self._rule(pattern='.*', manufacturer=self.dell) + self.assertFalse(rule.matches('x', manufacturer_id=None)) + + def test_manufacturer_unset_is_wildcard(self): + rule = self._rule(pattern='.*', manufacturer=None) + self.assertTrue(rule.matches('x', manufacturer_id=self.dell.pk)) + self.assertTrue(rule.matches('x', manufacturer_id=None)) + + def test_all_four_criteria_anded(self): + rule = self._rule(pattern='Linux', role_pattern='^Server', require_tags='critical', manufacturer=self.dell) + self.assertTrue(rule.matches('Ubuntu Linux', role_name='Server', netbox_tags={'critical'}, manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('Windows', role_name='Server', netbox_tags={'critical'}, manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('Ubuntu Linux', role_name='Switch', netbox_tags={'critical'}, manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('Ubuntu Linux', role_name='Server', netbox_tags={'other'}, manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('Ubuntu Linux', role_name='Server', netbox_tags={'critical'}, manufacturer_id=self.hpe.pk)) + + def test_dell_and_server_without_tag(self): + """Happy path for iDRAC: Dell ∧ Server, no NetBox tag required.""" + rule = self._rule(pattern='.*', role_pattern='^Server$', manufacturer=self.dell) + self.assertTrue(rule.matches('Ubuntu', role_name='Server', manufacturer_id=self.dell.pk)) + self.assertFalse(rule.matches('Ubuntu', role_name='Server', manufacturer_id=self.hpe.pk)) + self.assertFalse(rule.matches('Ubuntu', role_name='Storage', manufacturer_id=self.dell.pk)) + + def test_platformless_object_matches_catchall_only(self): + self.assertTrue(self._rule(pattern='.*').matches(None)) + self.assertFalse(self._rule(pattern='Windows').matches(None)) + + def test_disabled_or_garbage_never_raises_and_never_matches(self): + r = self._rule(pattern='.*') + r.enabled = False + self.assertFalse(r.matches('Ubuntu')) + + +class TemplateRuleCompoundCriteriaCleanTestCase(TestCase): + def test_role_pattern_nested_quantifier_rejected(self): + from django.core.exceptions import ValidationError + + rule = ZabbixTemplateRule(name='bad', pattern='.*', role_pattern='(a+)+$', require_tags='') + with self.assertRaises(ValidationError): + rule.clean() + + def test_require_tags_rejects_non_slug(self): + from django.core.exceptions import ValidationError + + rule = ZabbixTemplateRule(name='bad', pattern='.*', role_pattern='', require_tags='Critical!') + with self.assertRaises(ValidationError): + rule.clean() + + def test_manufacturer_protect_blocks_delete(self): + server = ZabbixServer.objects.create(name='Protect Zabbix', url='http://zabbix.local', token='abc', validate_certs=True) + template = ZabbixTemplate.objects.create(name='T', zabbixserver=server, templateid=99) + mfr = Manufacturer.objects.create(name='ProtectMe', slug='protect-me') + ZabbixTemplateRule.objects.create(name='scoped', pattern='.*', zabbixtemplate=template, manufacturer=mfr) + with self.assertRaises(models.ProtectedError): + mfr.delete() + + +class TemplateRuleCompoundResolutionTestCase(TestCase): + """End-to-end through the resolver: criteria compound against the object.""" + + def setUp(self): + self.server = ZabbixServer.objects.create(name='Res Zabbix', url='http://zabbix.local', token='abc123', validate_certs=True) + self.hostgroup = ZabbixHostgroup.objects.create(name='Rule Group', value='Rule Group', zabbixserver=self.server) + self.template = ZabbixTemplate.objects.create(name='T', zabbixserver=self.server, templateid=43) + self.tag = NetBoxTag.objects.create(name='critical', slug='critical') + self.dell = Manufacturer.objects.create(name='Dell', slug='dell-resolve') + self.hpe = Manufacturer.objects.create(name='HPE', slug='hpe-resolve') + + def test_rule_fires_only_when_all_criteria_match(self): + rule = ZabbixTemplateRule.objects.create( + name='combo', + pattern='.*', + zabbixtemplate=self.template, + role_pattern=f'^{self.tag.slug[:2]}', + require_tags=str(self.tag.slug), + ) + # device role is 'Device Role' from create_test_device, platform None + device = create_test_device(name='ComboDev') + result = get_assigned_zabbixobjects(device) + self.assertNotIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in result['templates']]) + + # tag the device -> tag criterion still fails (role pattern does not match role name) + device.tags.add(self.tag) + result = get_assigned_zabbixobjects(device) + self.assertNotIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in result['templates']]) + + # role pattern that matches the test device's role + tag present -> fires + rule.role_pattern = '.*' + rule.save() + result = get_assigned_zabbixobjects(device) + self.assertIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in result['templates']]) + + def test_manufacturer_and_role_resolution(self): + """Dell ∧ Server links; HPE Server and Dell non-Server do not.""" + from dcim.models import DeviceType, Site + + server_role, _ = DeviceRole.objects.get_or_create(name='Server', defaults={'slug': 'server-mfr-rule'}) + storage_role, _ = DeviceRole.objects.get_or_create(name='Storage', defaults={'slug': 'storage-mfr-rule'}) + site = Site.objects.create(name='MfrRuleSite', slug='mfr-rule-site') + dell_dt = DeviceType.objects.create(manufacturer=self.dell, model='PowerEdge R740', slug='poweredge-r740-mfr') + hpe_dt = DeviceType.objects.create(manufacturer=self.hpe, model='ProLiant DL380', slug='proliant-dl380-mfr') + dell_storage_dt = DeviceType.objects.create(manufacturer=self.dell, model='ME4 Storage', slug='me4-storage-mfr') + + rule = ZabbixTemplateRule.objects.create( + name='Dell iDRAC', + pattern='.*', + role_pattern='^Server$', + zabbixtemplate=self.template, + manufacturer=self.dell, + priority=80, + ) + + dell_server = create_test_device(name='DellServer') + dell_server.device_type = dell_dt + dell_server.role = server_role + dell_server.site = site + dell_server.save() + + hpe_server = create_test_device(name='HPEServer') + hpe_server.device_type = hpe_dt + hpe_server.role = server_role + hpe_server.site = site + hpe_server.save() + + dell_storage = create_test_device(name='DellStorage') + dell_storage.device_type = dell_storage_dt + dell_storage.role = storage_role + dell_storage.site = site + dell_storage.save() + + self.assertIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in get_assigned_zabbixobjects(dell_server)['templates']]) + self.assertNotIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in get_assigned_zabbixobjects(hpe_server)['templates']]) + self.assertNotIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in get_assigned_zabbixobjects(dell_storage)['templates']]) + + def test_existing_rules_without_manufacturer_unchanged(self): + rule = ZabbixTemplateRule.objects.create(name='platform-only', pattern='.*', zabbixtemplate=self.template) + device = create_test_device(name='AnyMfr') + result = get_assigned_zabbixobjects(device) + self.assertIn(rule.zabbixtemplate_id, [t.zabbixtemplate_id for t in result['templates']]) diff --git a/nbxsync/tests/models/test_zabbixhostgroupassignment.py b/nbxsync/tests/models/test_zabbixhostgroupassignment.py index 5e206abb..db6a6df2 100644 --- a/nbxsync/tests/models/test_zabbixhostgroupassignment.py +++ b/nbxsync/tests/models/test_zabbixhostgroupassignment.py @@ -88,8 +88,20 @@ def test_get_context_keys(self): assignment = ZabbixHostgroupAssignment.objects.create(zabbixhostgroup=self.group, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) context = assignment.get_context() self.assertIn('object', context) + self.assertIn('device', context) self.assertIn('value', context) self.assertIn('name', context) + self.assertEqual(context['device'], self.device) + + def test_render_can_use_related_device_context(self): + self.group.value = '{{ site.name }}/{{ device.name }}' + self.group.save() + + assignment = ZabbixHostgroupAssignment.objects.create(zabbixhostgroup=self.group, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) + rendered, success = assignment.render() + + self.assertTrue(success) + self.assertEqual(rendered, f'{self.device.site.name}/{self.device.name}') def test_render_unexpected_exception(self): self.group.value = '{{ object.name }}' diff --git a/nbxsync/tests/models/test_zabbixtagassignment.py b/nbxsync/tests/models/test_zabbixtagassignment.py index 2c163df2..53df9f9a 100644 --- a/nbxsync/tests/models/test_zabbixtagassignment.py +++ b/nbxsync/tests/models/test_zabbixtagassignment.py @@ -88,6 +88,33 @@ def test_get_context_values(self): self.assertEqual(context['name'], self.tag.name) self.assertEqual(context['description'], self.tag.description) self.assertEqual(context['extra'], 'extra_val') + self.assertEqual(context['device'], self.device) + self.assertEqual(context['site'], self.device.site) + self.assertEqual(context['device_type'], self.device.device_type) + self.assertEqual(context['manufacturer'], self.device.device_type.manufacturer) + + def test_get_context_includes_related_device_objects(self): + self.device.device_type = self.device_type + self.device.save() + + assignment = ZabbixTagAssignment.objects.create(zabbixtag=self.tag, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) + context = assignment.get_context() + + self.assertEqual(context['site'], self.device.site) + self.assertEqual(context['device_type'], self.device_type) + self.assertEqual(context['manufacturer'], self.manufacturer) + + def test_render_can_use_related_device_context(self): + self.device.device_type = self.device_type + self.device.save() + self.tag.value = '{{ site.name }} - {{ device_type.model }} - {{ manufacturer.name }}' + self.tag.save() + + assignment = ZabbixTagAssignment.objects.create(zabbixtag=self.tag, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) + rendered, success = assignment.render() + + self.assertTrue(success) + self.assertEqual(rendered, f'{self.device.site.name} - {self.device_type.model} - {self.manufacturer.name}') def test_str_method_with_name(self): assignment = ZabbixTagAssignment.objects.create(zabbixtag=self.tag, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) diff --git a/nbxsync/tests/models/test_zabbixtemplaterule_guard.py b/nbxsync/tests/models/test_zabbixtemplaterule_guard.py new file mode 100644 index 00000000..761a0422 --- /dev/null +++ b/nbxsync/tests/models/test_zabbixtemplaterule_guard.py @@ -0,0 +1,86 @@ +"""Tests for safe regex evaluation behind template rules. + +Rule patterns are operator-supplied and matched against every synced platform +name. Evaluation must never raise, must reject nested-quantifier ReDoS shapes +at save time, and must not touch process-global signals (RQ job timeouts). +""" + +import threading + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from nbxsync.models import ZabbixHostgroup, ZabbixServer, ZabbixTemplate, ZabbixTemplateRule +from nbxsync.models.zabbixtemplaterule import _MAX_MATCH_INPUT + + +class TemplateRuleRegexGuardTestCase(TestCase): + def setUp(self): + self.server = ZabbixServer.objects.create(name='Guard Server', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Linux by Zabbix agent', zabbixserver=self.server, templateid=10001) + self.rule = ZabbixTemplateRule.objects.create(name='Linux', pattern='Ubuntu', zabbixtemplate=self.template) + + def test_matches_in_the_main_thread(self): + self.assertTrue(self.rule.matches('Ubuntu 24.04 LTS')) + self.assertFalse(self.rule.matches('Windows Server 2022')) + + def test_matches_from_a_worker_thread(self): + results = [] + thread = threading.Thread(target=lambda: results.append(self.rule.matches('Ubuntu 24.04 LTS'))) + thread.start() + thread.join() + + self.assertEqual(results, [True]) + + def test_oversized_input_does_not_match(self): + with self.assertLogs('nbxsync.models.zabbixtemplaterule', level='WARNING'): + self.assertFalse(self.rule.matches('U' * (_MAX_MATCH_INPUT + 1))) + + def test_invalid_stored_pattern_does_not_match(self): + ZabbixTemplateRule.objects.filter(pk=self.rule.pk).update(pattern='Windows (') + self.rule.refresh_from_db() + + with self.assertLogs('nbxsync.models.zabbixtemplaterule', level='ERROR'): + self.assertFalse(self.rule.matches('Windows Server 2022')) + + def test_invalid_pattern_is_rejected_on_save(self): + rule = ZabbixTemplateRule(name='Broken', pattern='Windows (', zabbixtemplate=self.template) + + with self.assertRaises(ValidationError) as context: + rule.full_clean() + + self.assertIn('pattern', context.exception.message_dict) + + def test_nested_quantifier_pattern_is_rejected_on_save(self): + rule = ZabbixTemplateRule(name='ReDoS', pattern=r'(a+)+$', zabbixtemplate=self.template) + + with self.assertRaises(ValidationError) as context: + rule.full_clean() + + self.assertIn('pattern', context.exception.message_dict) + + def test_hostgroup_must_share_the_template_server(self): + other = ZabbixServer.objects.create(name='Other Server', url='http://other.local', token='xyz', validate_certs=True) + foreign_group = ZabbixHostgroup.objects.create(name='Foreign', zabbixserver=other, groupid=42) + rule = ZabbixTemplateRule( + name='Cross-server', + pattern='Ubuntu', + zabbixtemplate=self.template, + zabbixhostgroup=foreign_group, + ) + + with self.assertRaises(ValidationError) as context: + rule.full_clean() + + self.assertIn('zabbixhostgroup', context.exception.message_dict) + + def test_hostgroup_on_same_server_is_accepted(self): + group = ZabbixHostgroup.objects.create(name='Linux', zabbixserver=self.server, groupid=7) + rule = ZabbixTemplateRule( + name='Same-server', + pattern='Ubuntu', + zabbixtemplate=self.template, + zabbixhostgroup=group, + ) + + rule.full_clean() # must not raise diff --git a/nbxsync/tests/query_counts.json b/nbxsync/tests/query_counts.json index 0f69de06..6168b3e6 100644 --- a/nbxsync/tests/query_counts.json +++ b/nbxsync/tests/query_counts.json @@ -2,7 +2,7 @@ "zabbixconfigurationgroup:list_objects_with_permission": 18, "zabbixconfigurationgroupassignment:list_objects_with_permission": 20, "zabbixhostgroup:list_objects_with_permission": 19, - "zabbixhostgroupassignment:list_objects_with_permission": 20, + "zabbixhostgroupassignment:list_objects_with_permission": 28, "zabbixhostinterface:list_objects_with_permission": 21, "zabbixhostinventory:list_objects_with_permission": 22, "zabbixmacro:list_objects_with_permission": 22, @@ -12,7 +12,9 @@ "zabbixserver:list_objects_with_permission": 18, "zabbixserverassignment:list_objects_with_permission": 22, "zabbixtag:list_objects_with_permission": 19, - "zabbixtagassignment:list_objects_with_permission": 20, + "zabbixtagassignment:list_objects_with_permission": 28, "zabbixtemplate:list_objects_with_permission": 19, - "zabbixtemplateassignment:list_objects_with_permission": 20 + "zabbixtemplateassignment:list_objects_with_permission": 20, + "zabbixtemplaterule:api_list_objects": 15, + "zabbixtemplaterule:list_objects_with_permission": 21 } diff --git a/nbxsync/tests/signals/test_nbobjects.py b/nbxsync/tests/signals/test_nbobjects.py new file mode 100644 index 00000000..c274e143 --- /dev/null +++ b/nbxsync/tests/signals/test_nbobjects.py @@ -0,0 +1,71 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Device +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixHostBinding, ZabbixServer, ZabbixServerAssignment +from nbxsync.signals.nbobjects import handle_deleted_object + + +class NetBoxObjectDeleteSignalTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.server = ZabbixServer.objects.create(name='Zabbix', url='http://zabbix.local', token='token') + cls.device = create_test_device(name='delete-signal-device') + cls.device_ct = ContentType.objects.get_for_model(Device) + + @patch('nbxsync.signals.nbobjects.get_queue') + def test_delete_is_enqueued_after_commit_with_binding_ids(self, mock_get_queue): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=4_000_000_001, + hostname=self.device.name, + ) + queue = MagicMock() + mock_get_queue.return_value = queue + + with self.captureOnCommitCallbacks(execute=True): + handle_deleted_object(Device, self.device) + mock_get_queue.assert_not_called() + + mock_get_queue.assert_called_once_with('low') + _, kwargs = queue.create_job.call_args + self.assertEqual(kwargs['func'], 'nbxsync.worker.deletehost') + self.assertEqual(kwargs['args'], [(binding.pk,)]) + self.assertEqual(kwargs['retry'].max, 5) + self.assertTrue(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + @patch('nbxsync.signals.nbobjects.get_queue') + def test_delete_migrates_legacy_direct_hostid_before_commit(self, mock_get_queue): + assignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=4_000_000_004, + ) + queue = MagicMock() + mock_get_queue.return_value = queue + + with self.captureOnCommitCallbacks(execute=True): + handle_deleted_object(Device, self.device) + + binding = ZabbixHostBinding.objects.get( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + ) + self.assertEqual(binding.hostid, assignment.hostid) + _, kwargs = queue.create_job.call_args + self.assertEqual(kwargs['args'], [(binding.pk,)]) + + @patch('nbxsync.signals.nbobjects.get_queue') + def test_delete_without_binding_does_not_enqueue_unsafe_name_lookup(self, mock_get_queue): + with self.captureOnCommitCallbacks(execute=True): + handle_deleted_object(Device, self.device) + + mock_get_queue.assert_not_called() diff --git a/nbxsync/tests/systemjobs/test_sync_objects.py b/nbxsync/tests/systemjobs/test_sync_objects.py index 9e8a8a80..7c97c710 100644 --- a/nbxsync/tests/systemjobs/test_sync_objects.py +++ b/nbxsync/tests/systemjobs/test_sync_objects.py @@ -2,11 +2,12 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from virtualization.models import VirtualMachine -from dcim.models import Device +from dcim.models import Device, Site, SiteGroup from utilities.testing import create_test_device -from nbxsync.models import ZabbixConfigurationGroup, ZabbixServer, ZabbixServerAssignment +from nbxsync.models import ZabbixConfigurationGroup, ZabbixHostBinding, ZabbixServer, ZabbixServerAssignment from nbxsync.systemjobs.sync_objects import SyncObjectsJob @@ -18,6 +19,23 @@ def setUpTestData(cls): cls.device2 = create_test_device(name='SyncObjects Dev 2') cls.device_ct = ContentType.objects.get_for_model(Device) cls.cfg_ct = ContentType.objects.get_for_model(ZabbixConfigurationGroup) + cls.site_ct = ContentType.objects.get_for_model(Site) + cls.sitegroup_ct = ContentType.objects.get_for_model(SiteGroup) + cls.vm_ct = ContentType.objects.get_for_model(VirtualMachine) + + def setUp(self): + self.job_active_patcher = patch('nbxsync.systemjobs.sync_objects._job_is_active', return_value=False) + self.mock_job_is_active = self.job_active_patcher.start() + self.addCleanup(self.job_active_patcher.stop) + + @staticmethod + def _ref(instance): + content_type = ContentType.objects.get_for_model(instance) + return (content_type.app_label, content_type.model, instance.pk) + + @staticmethod + def _enqueued_refs(queue): + return [tuple(call.kwargs['args']) for call in queue.create_job.call_args_list] @patch('nbxsync.systemjobs.sync_objects.get_queue') def test_run_enqueues_job_for_each_device(self, mock_get_queue): @@ -33,9 +51,9 @@ def test_run_enqueues_job_for_each_device(self, mock_get_queue): self.assertEqual(queue.create_job.call_count, 2) self.assertEqual(queue.enqueue_job.call_count, 2) - enqueued_instances = [call.kwargs.get('args')[0] for call in queue.create_job.call_args_list] - self.assertIn(self.device1, enqueued_instances) - self.assertIn(self.device2, enqueued_instances) + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(self.device1), enqueued_refs) + self.assertIn(self._ref(self.device2), enqueued_refs) @patch('nbxsync.systemjobs.sync_objects.get_queue') def test_run_passes_correct_args_to_create_job(self, mock_get_queue): @@ -50,7 +68,8 @@ def test_run_passes_correct_args_to_create_job(self, mock_get_queue): _, kwargs = queue.create_job.call_args self.assertEqual(kwargs.get('func'), 'nbxsync.worker.synchost') self.assertEqual(kwargs.get('timeout'), 9000) - self.assertEqual(kwargs.get('args')[0], self.device1) + self.assertEqual(tuple(kwargs.get('args')), self._ref(self.device1)) + self.assertEqual(kwargs.get('job_id'), f'nbxsync-host-dcim-device-{self.device1.pk}') @patch('nbxsync.systemjobs.sync_objects.get_queue') def test_run_skips_configurationgroup_assignments(self, mock_get_queue): @@ -66,26 +85,159 @@ def test_run_skips_configurationgroup_assignments(self, mock_get_queue): mock_get_queue.assert_not_called() @patch('nbxsync.systemjobs.sync_objects.get_queue') - def test_run_stops_entirely_on_duplicate_assigned_object(self, mock_get_queue): + def test_run_enqueues_devices_for_sitegroup_assignment(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + sitegroup = SiteGroup.objects.create(name='CH', slug='ch') + site = Site.objects.create(name='CH-STA', slug='ch-sta', group=sitegroup) + device = create_test_device(name='Dev-at-CH-STA', site=site) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.sitegroup_ct, assigned_object_id=sitegroup.pk) + job = SyncObjectsJob(job=MagicMock()) + job.run() + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(device), enqueued_refs) + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_enqueues_vms_for_sitegroup_assignment(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + sitegroup = SiteGroup.objects.create(name='CH', slug='ch') + site = Site.objects.create(name='CH-STA', slug='ch-sta', group=sitegroup) + vm = VirtualMachine.objects.create(name='VM-at-CH-STA', site=site) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.sitegroup_ct, assigned_object_id=sitegroup.pk) + job = SyncObjectsJob(job=MagicMock()) + job.run() + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(vm), enqueued_refs) + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_continues_on_duplicate_device(self, mock_get_queue): queue = MagicMock() mock_get_queue.return_value = queue + server2 = ZabbixServer.objects.create(name='Zabbix Server 2', url='http://zabbix2.local', token='token2') + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=self.device1.pk) + ZabbixServerAssignment.objects.create(zabbixserver=server2, assigned_object_type=self.device_ct, assigned_object_id=self.device1.pk) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=self.device2.pk) + job = SyncObjectsJob(job=MagicMock()) + job.run() + self.assertEqual(queue.create_job.call_count, 2) + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(self.device1), enqueued_refs) + self.assertIn(self._ref(self.device2), enqueued_refs) - device3 = create_test_device(name='SyncObjects Dev 3') + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_enqueues_devices_for_site_assignment(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + site = Site.objects.create(name='Site-Test', slug='site-test') + device = create_test_device(name='Dev-at-Site-Test', site=site) + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.site_ct, assigned_object_id=site.pk) + job = SyncObjectsJob(job=MagicMock()) + job.run() + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(device), enqueued_refs) + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_skips_disabled_assignment(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=self.device1.pk) - # Second assignment for device1 triggers the `return` server2 = ZabbixServer.objects.create(name='Zabbix Server 2', url='http://zabbix2.local', token='token2') + ZabbixServerAssignment.objects.create(zabbixserver=server2, assigned_object_type=self.device_ct, assigned_object_id=self.device2.pk, sync_enabled=False) + job = SyncObjectsJob(job=MagicMock()) + job.run() + self.assertEqual(queue.create_job.call_count, 1) + enqueued_refs = self._enqueued_refs(queue) + self.assertIn(self._ref(self.device1), enqueued_refs) + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_skips_disabled_zabbixserver(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + server2 = ZabbixServer.objects.create(name='Zabbix Server 2', url='http://zabbix2.local', token='token2', sync_enabled=False) ZabbixServerAssignment.objects.create(zabbixserver=server2, assigned_object_type=self.device_ct, assigned_object_id=self.device1.pk) - ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=device3.pk) + job = SyncObjectsJob(job=MagicMock()) + job.run() + mock_get_queue.assert_not_called() + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_skips_job_already_queued_or_running(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + self.mock_job_is_active.return_value = True + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device1.pk, + ) + + SyncObjectsJob(job=MagicMock()).run() + self.mock_job_is_active.assert_called_once_with(queue, f'nbxsync-host-dcim-device-{self.device1.pk}') + queue.create_job.assert_not_called() + queue.enqueue_job.assert_not_called() + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_handles_empty_sitegroup(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + sitegroup = SiteGroup.objects.create(name='Empty', slug='empty') + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.sitegroup_ct, assigned_object_id=sitegroup.pk) job = SyncObjectsJob(job=MagicMock()) job.run() + mock_get_queue.assert_not_called() - # Only device1 is enqueued; the loop exits on the duplicate before reaching device3 - self.assertEqual(queue.create_job.call_count, 2) - enqueued_instances = [call.kwargs.get('args')[0] for call in queue.create_job.call_args_list] - self.assertIn(self.device1, enqueued_instances) - self.assertIn(device3, enqueued_instances) + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_enqueues_bound_device_after_assignment_removed(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device1.pk, + hostid=123, + hostname=self.device1.name, + ) + + SyncObjectsJob(job=MagicMock()).run() + + self.assertEqual(self._enqueued_refs(queue), [self._ref(self.device1)]) + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_deduplicates_assignment_and_binding_candidates(self, mock_get_queue): + queue = MagicMock() + mock_get_queue.return_value = queue + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device1.pk, + ) + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device1.pk, + hostid=456, + hostname=self.device1.name, + ) + + SyncObjectsJob(job=MagicMock()).run() + + self.assertEqual(queue.create_job.call_count, 1) + self.assertEqual(self._enqueued_refs(queue), [self._ref(self.device1)]) + + @patch('nbxsync.systemjobs.sync_objects.get_queue') + def test_run_skips_binding_whose_object_no_longer_exists(self, mock_get_queue): + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=999999, + hostid=789, + hostname='deleted-device', + ) + + SyncObjectsJob(job=MagicMock()).run() + + mock_get_queue.assert_not_called() @patch('nbxsync.systemjobs.sync_objects.get_queue') def test_run_does_nothing_when_no_assignments_exist(self, mock_get_queue): diff --git a/nbxsync/tests/templatetags/__init__.py b/nbxsync/tests/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nbxsync/tests/templatetags/test_assignment_render.py b/nbxsync/tests/templatetags/test_assignment_render.py new file mode 100644 index 00000000..823eae78 --- /dev/null +++ b/nbxsync/tests/templatetags/test_assignment_render.py @@ -0,0 +1,276 @@ +"""Tests for assignment-target Jinja rendering (device-shaped wrap). + +Device-context templates like ``Roles/{{ object.role.name }}`` must resolve +when assigned to a DeviceRole without borrowing a descendant Device. SiteGroup +assignments that need a per-device role cannot resolve to one Value — the UI +shows the raw template; sync still renders per Device/VM. +""" + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Device, DeviceRole, Site, SiteGroup +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment +from nbxsync.templatetags.zabbix_hostgroups import render_zabbix_hostgroup_assignment +from nbxsync.templatetags.zabbix_tags import render_zabbix_tag_assignment +from nbxsync.jinja_context import related_template_context, wrap_assignment_object + + +def _make_device(name, role=None, site=None): + device = create_test_device(name=name, site=site) + if role is not None: + device.role = role + device.save() + return device + + +class AssignmentRenderWrapTestCase(TestCase): + def test_devicerole_exposes_role_and_name(self): + role = DeviceRole.objects.create(name='Switch Access', slug='sw-acc-wrap') + wrapped = wrap_assignment_object(role) + self.assertIs(wrapped.role, role) + self.assertEqual(wrapped.name, 'Switch Access') + + def test_site_exposes_site(self): + site = Site.objects.create(name='CH-SITE', slug='ch-site-wrap') + wrapped = wrap_assignment_object(site) + self.assertIs(wrapped.site, site) + + def test_sitegroup_left_unchanged(self): + group = SiteGroup.objects.create(name='CH', slug='ch-wrap') + self.assertIs(wrap_assignment_object(group), group) + + def test_device_left_unchanged(self): + device = _make_device(name='wrap-dev') + self.assertIs(wrap_assignment_object(device), device) + + +class HostgroupAssignmentRenderTestCase(TestCase): + def setUp(self): + self.zabbixserver = ZabbixServer.objects.create(name='Render Fix Server') + + def test_devicerole_roles_template_resolves(self): + """Roles/{{ object.role.name }} on DeviceRole → Roles/.""" + role = DeviceRole.objects.create(name='Network Device', slug='network-device') + hg = ZabbixHostgroup.objects.create( + name='Roles', + value='Roles/{{ object.role.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(DeviceRole) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=role.id, + ) + + output, ok = assignment.render() + self.assertTrue(ok) + self.assertEqual(output, 'Roles/Network Device') + self.assertEqual(render_zabbix_hostgroup_assignment({}, assignment), 'Roles/Network Device') + + def test_sitegroup_roles_shows_template_not_sample(self): + """Roles Jinja on SiteGroup cannot resolve to one role — show template.""" + parent = SiteGroup.objects.create(name='CH', slug='ch-render') + site = Site.objects.create(name='CH-SITE', slug='ch-site-render', group=parent) + role_sw = DeviceRole.objects.create(name='Switch Access', slug='sw-acc-render') + role_fw = DeviceRole.objects.create(name='Firewall', slug='fw-render') + _make_device(name='ch-sw-1', role=role_sw, site=site) + _make_device(name='ch-fw-1', role=role_fw, site=site) + + hg = ZabbixHostgroup.objects.create( + name='Roles', + value='Roles/{{ object.role.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(SiteGroup) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=parent.id, + ) + + output, ok = assignment.render() + self.assertFalse(ok) + self.assertEqual( + render_zabbix_hostgroup_assignment({}, assignment), + 'Roles/{{ object.role.name }}', + ) + + # Sync path still renders per device. + synced = {assignment.render(object=d)[0] for d in Device.objects.filter(site=site)} + self.assertEqual(synced, {'Roles/Switch Access', 'Roles/Firewall'}) + + def test_site_sites_template_resolves(self): + group = SiteGroup.objects.create(name='CH', slug='ch-sites') + site = Site.objects.create(name='CH-STA-L44', slug='ch-sta-l44', group=group) + hg = ZabbixHostgroup.objects.create( + name='Sites', + value='Sites/{{ object.site.group.name }}/{{ object.site.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(Site) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=site.id, + ) + + output, ok = assignment.render() + self.assertTrue(ok) + self.assertEqual(output, 'Sites/CH/CH-STA-L44') + + def test_explicit_object_overrides_wrap(self): + role = DeviceRole.objects.create(name='Override Role', slug='override-role') + hg = ZabbixHostgroup.objects.create( + name='Roles', + value='Roles/{{ object.role.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(DeviceRole) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=role.id, + ) + + other_role = DeviceRole.objects.create(name='Other Role', slug='other-role') + explicit_device = _make_device(name='explicit-dev', role=other_role) + + rendered = render_zabbix_hostgroup_assignment({}, assignment, object=explicit_device) + self.assertEqual(rendered, 'Roles/Other Role') + + def test_static_value_passes_through(self): + hg = ZabbixHostgroup.objects.create( + name='Managed', + value='Managed/nbxSync', + zabbixserver=self.zabbixserver, + ) + role = DeviceRole.objects.create(name='Any Role', slug='any-role') + ct = ContentType.objects.get_for_model(DeviceRole) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=role.id, + ) + + self.assertEqual(render_zabbix_hostgroup_assignment({}, assignment), 'Managed/nbxSync') + + +class TagAssignmentRenderTestCase(TestCase): + def setUp(self): + self.zabbixserver = ZabbixServer.objects.create(name='Tag Render Server') + + def test_devicerole_tag_resolves(self): + role = DeviceRole.objects.create(name='Switch Core', slug='switch-core') + tag = ZabbixTag.objects.create( + name='owner', + tag='owner', + value='{{ object.role.name }}', + ) + ct = ContentType.objects.get_for_model(DeviceRole) + assignment = ZabbixTagAssignment.objects.create( + zabbixtag=tag, + assigned_object_type=ct, + assigned_object_id=role.id, + ) + + self.assertEqual(render_zabbix_tag_assignment({}, assignment), 'Switch Core') + + def test_sitegroup_tag_shows_template(self): + group = SiteGroup.objects.create(name='HU', slug='hu-tag') + tag = ZabbixTag.objects.create( + name='owner', + tag='owner', + value='{{ object.role.name }}', + ) + ct = ContentType.objects.get_for_model(SiteGroup) + assignment = ZabbixTagAssignment.objects.create( + zabbixtag=tag, + assigned_object_type=ct, + assigned_object_id=group.id, + ) + + self.assertEqual(render_zabbix_tag_assignment({}, assignment), '{{ object.role.name }}') + + +class RelatedTemplateContextTestCase(TestCase): + """#102 shorthand aliases follow the render object, not the assignment row.""" + + def setUp(self): + self.zabbixserver = ZabbixServer.objects.create(name='Related Ctx Server') + self.device = _make_device(name='related-dev') + + def test_device_context_exposes_aliases(self): + context = related_template_context(self.device) + self.assertIs(context['device'], self.device) + self.assertEqual(context['site'], self.device.site) + self.assertEqual(context['role'], self.device.role) + self.assertEqual(context['device_type'], self.device.device_type) + self.assertEqual(context['manufacturer'], self.device.device_type.manufacturer) + + def test_site_wrap_exposes_site_but_not_device(self): + site = Site.objects.create(name='Alias Site', slug='alias-site') + context = related_template_context(wrap_assignment_object(site)) + self.assertIs(context['site'], site) + self.assertNotIn('device', context) + + def test_hostgroup_render_uses_site_and_device_aliases(self): + hg = ZabbixHostgroup.objects.create( + name='Sites', + value='{{ site.name }}/{{ device.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(Device) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=self.device.id, + ) + output, ok = assignment.render() + self.assertTrue(ok) + self.assertEqual(output, f'{self.device.site.name}/{self.device.name}') + + def test_inherited_assignment_aliases_follow_sync_host_not_role(self): + """A Role-level template rendered with object=device must use the device.""" + assigned_role = DeviceRole.objects.create(name='Assigned Role', slug='assigned-role-alias') + host_role = DeviceRole.objects.create(name='Host Role', slug='host-role-alias') + host = _make_device(name='alias-host', role=host_role) + hg = ZabbixHostgroup.objects.create( + name='Roles', + value='{{ role.name }}/{{ device.name }}', + zabbixserver=self.zabbixserver, + ) + ct = ContentType.objects.get_for_model(DeviceRole) + assignment = ZabbixHostgroupAssignment.objects.create( + zabbixhostgroup=hg, + assigned_object_type=ct, + assigned_object_id=assigned_role.id, + ) + + # Role preview has `role` but not `device` — do not pretend the Role is a Device. + preview, preview_ok = assignment.render() + self.assertFalse(preview_ok) + + synced, synced_ok = assignment.render(object=host) + self.assertTrue(synced_ok) + self.assertEqual(synced, f'{host_role.name}/{host.name}') + + def test_tag_render_related_fields(self): + tag = ZabbixTag.objects.create( + name='hw', + tag='hw', + value='{{ site.name }} - {{ device_type.model }} - {{ manufacturer.name }}', + ) + ct = ContentType.objects.get_for_model(Device) + assignment = ZabbixTagAssignment.objects.create( + zabbixtag=tag, + assigned_object_type=ct, + assigned_object_id=self.device.id, + ) + self.assertEqual( + render_zabbix_tag_assignment({}, assignment), + f'{self.device.site.name} - {self.device.device_type.model} - {self.device.device_type.manufacturer.name}', + ) 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/test_worker.py b/nbxsync/tests/test_worker.py index b8c9037b..51ece058 100644 --- a/nbxsync/tests/test_worker.py +++ b/nbxsync/tests/test_worker.py @@ -1,21 +1,43 @@ from unittest.mock import MagicMock, patch +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from nbxsync.models import ZabbixServer -from nbxsync.worker import synchost, syncproxy, syncproxygroup, synctemplates +from nbxsync.worker import deletehost, synchost, syncproxy, syncproxygroup, synctemplates class RQJobTests(TestCase): def setUp(self): self.instance = ZabbixServer.objects.create(name='Test Server', url='http://example.com', token='abc123', validate_certs=True) + self.content_type = ContentType.objects.get_for_model(self.instance) @patch('nbxsync.worker.SyncHostJob') def test_synchost_runs_job(self, mock_job_class): mock_job = MagicMock() mock_job_class.return_value = mock_job - synchost(self.instance) + synchost(self.content_type.app_label, self.content_type.model, self.instance.pk) + + mock_job_class.assert_called_once_with(instance=self.instance) + mock_job.run.assert_called_once() + + @patch('nbxsync.worker.DeleteHostJob') + def test_deletehost_runs_job_with_binding_ids(self, mock_job_class): + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + deletehost([11, 12]) + + mock_job_class.assert_called_once_with(binding_ids=[11, 12]) + mock_job.run.assert_called_once() + + @patch('nbxsync.worker.DeleteHostJob') + def test_deletehost_preserves_legacy_instance_job_compatibility(self, mock_job_class): + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + deletehost(self.instance) mock_job_class.assert_called_once_with(instance=self.instance) mock_job.run.assert_called_once() diff --git a/nbxsync/tests/utils/test_duplicate_recovery.py b/nbxsync/tests/utils/test_duplicate_recovery.py new file mode 100644 index 00000000..4448c646 --- /dev/null +++ b/nbxsync/tests/utils/test_duplicate_recovery.py @@ -0,0 +1,85 @@ +"""Tests for recovering from a concurrent create in Zabbix. + +Two workers can create the same hostgroup/proxy/template between the lookup and +the create call. Recovering from that race must not swallow genuine validation +errors, so only Zabbix's -32602 'already exists' response is treated as a race. +""" + +from django.test import TestCase + +from nbxsync.utils.sync.syncbase import ZabbixSyncBase + + +class _ApiError(Exception): + def __init__(self, message, code=None, data=''): + super().__init__(f'{message} {data}'.strip()) + if code is not None: + self.code = code + self.data = data + self.message = message + + +class DuplicateErrorDetectionTestCase(TestCase): + def test_duplicate_params_error_is_recognised(self): + err = _ApiError('Invalid params.', code=-32602, data='Host group "Linux servers" already exists.') + + self.assertTrue(ZabbixSyncBase._is_duplicate_error(err)) + + def test_other_invalid_params_error_is_not_a_duplicate(self): + err = _ApiError('Invalid params.', code=-32602, data='Incorrect value for field "name": cannot be empty.') + + self.assertFalse(ZabbixSyncBase._is_duplicate_error(err)) + + def test_internal_error_is_not_a_duplicate(self): + err = _ApiError('Internal error.', code=-32500, data='already exists somewhere') + + self.assertFalse(ZabbixSyncBase._is_duplicate_error(err)) + + def test_plain_exception_falls_back_to_the_message(self): + self.assertTrue(ZabbixSyncBase._is_duplicate_error(Exception('Host group already exists'))) + self.assertFalse(ZabbixSyncBase._is_duplicate_error(Exception('Connection refused'))) + + +class _FakeApiObject: + def __init__(self, update_side_effect=None): + self.update_side_effect = update_side_effect + self.update_calls = 0 + + def update(self, **params): + self.update_calls += 1 + if self.update_side_effect is not None: + raise self.update_side_effect + + +class _UpdateSync(ZabbixSyncBase): + id_field = 'groupid' + name_field = 'name' + + def __init__(self, api_object): + self._api = api_object + self.obj = type('Obj', (), {'groupid': '23', 'name': 'Priority/Critical'})() + + def api_object(self): + return self._api + + def get_update_params(self, object_id=None): + return {'groupid': object_id or self.get_id(), 'name': self.obj.name} + + +class UpdateDuplicateRecoveryTestCase(TestCase): + def test_update_already_exists_is_treated_as_noop(self): + err = _ApiError('Invalid params.', code=-32602, data='Host group "Priority/Critical" already exists.') + api = _FakeApiObject(update_side_effect=err) + sync = _UpdateSync(api) + + sync.update_in_zabbix(object_id='23') + + self.assertEqual(api.update_calls, 1) + + def test_update_other_errors_still_raise(self): + err = _ApiError('Invalid params.', code=-32602, data='Incorrect value for field "name": cannot be empty.') + api = _FakeApiObject(update_side_effect=err) + sync = _UpdateSync(api) + + with self.assertRaises(_ApiError): + sync.update_in_zabbix(object_id='23') 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_host_binding.py b/nbxsync/tests/utils/test_host_binding.py new file mode 100644 index 00000000..60806be9 --- /dev/null +++ b/nbxsync/tests/utils/test_host_binding.py @@ -0,0 +1,544 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixHostBinding, ZabbixServer, ZabbixServerAssignment +from nbxsync.utils.host_binding import HostBindingDeleteProxy, backfill_or_resolve_conflict +from nbxsync.utils.sync import HostSync + + +class PluginSettingMixin: + """Temporarily override a validated plugin setting for one test.""" + + def _set_plugin_setting(self, name, value): + from nbxsync.settings import get_plugin_settings + + pluginsettings = get_plugin_settings() + original = getattr(pluginsettings, name) + setattr(pluginsettings, name, value) + self.addCleanup(setattr, pluginsettings, name, original) + + +class HostBindingTestCase(PluginSettingMixin, TestCase): + @classmethod + def setUpTestData(cls): + cls.server = ZabbixServer.objects.create(name='Zabbix', url='http://zabbix.local', token='t') + cls.device = create_test_device(name='binding-test') + cls.device_ct = ContentType.objects.get_for_model(cls.device) + cls.assignment = ZabbixServerAssignment.objects.create( + zabbixserver=cls.server, + assigned_object_type=cls.device_ct, + assigned_object_id=cls.device.pk, + ) + + def _all_objects(self): + return { + 'hostgroups': [], + 'tags': [], + 'macros': [], + 'hostinterfaces': [], + 'templates': [], + 'hostinventory': None, + '_instance': self.device, + } + + def _api(self, host_get=None): + api = MagicMock() + api.host.get.return_value = host_get if host_get is not None else [] + api.host.create.return_value = {'hostids': ['100']} + api.host.update.return_value = {'hostids': ['100']} + api.hostinterface.get.return_value = [] + api.template.get.return_value = [] + api.maintenance.get.return_value = [] + return api + + def _attach_assigned_objects(self, assignment): + assignment.assigned_objects = self._all_objects() + + def test_inherited_creation_persists_binding(self): + inherited = MagicMock() + inherited.zabbixserver = self.server + inherited.hostid = None + inherited.assigned_object = self.device + inherited.assigned_object_type = self.device_ct + inherited.assigned_object_id = self.device.pk + inherited._is_inherited_copy = True + inherited.pk = None + inherited.update_sync_info = MagicMock() + inherited.assigned_objects = self._all_objects() + + api = self._api() + sync = HostSync(api=api, netbox_obj=inherited, all_objects=self._all_objects()) + sync.sync() + + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 100) + + def test_second_sync_uses_same_hostid(self): + api = self._api() + self._attach_assigned_objects(self.assignment) + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + sync.sync() + + api.host.get.return_value = [{'hostid': '100', 'host': 'binding-test', 'name': 'binding-test'}] + api.host.create.reset_mock() + + sync2 = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + self._attach_assigned_objects(self.assignment) + sync2.sync() + + api.host.create.assert_not_called() + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 100) + + def test_rename_updates_same_hostid(self): + api = self._api() + self._attach_assigned_objects(self.assignment) + HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()).sync() + + self.device.name = 'renamed-binding-test' + self.device.save() + + api.host.get.return_value = [{'hostid': '100', 'host': 'binding-test', 'name': 'binding-test'}] + api.host.create.reset_mock() + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + self._attach_assigned_objects(self.assignment) + sync.sync() + + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 100) + self.assertEqual(binding.hostname, 'renamed-binding-test') + + def test_unmanaged_same_name_conflict(self): + def host_get(**kwargs): + if kwargs.get('hostids'): + return [] + if kwargs.get('filter', {}).get('host'): + return [{'hostid': '200', 'host': 'binding-test', 'name': 'binding-test', 'tags': []}] + return [] + + api = MagicMock() + api.host.get.side_effect = host_get + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + with self.assertRaises(RuntimeError): + sync.sync() + + def test_adoption_looks_up_technical_hostname(self): + self._set_plugin_setting('adopt_existing_hosts', True) + api = MagicMock() + api.host.get.return_value = [ + { + 'hostid': '321', + 'host': 'tech-host-01', + 'tags': [ + {'tag': 'nb_type', 'value': 'device'}, + {'tag': 'nb_id', 'value': str(self.device.pk)}, + ], + } + ] + + hostid = backfill_or_resolve_conflict(self.device, self.server, api, hostname='tech-host-01') + + self.assertEqual(hostid, 321) + api.host.get.assert_called_with(filter={'host': 'tech-host-01'}, selectTags='extend') + + def test_duplicate_managed_identity_conflict(self): + other_device = create_test_device(name='other-binding-test') + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=other_device.pk, + hostid=777, + hostname='other', + ) + self.assignment.hostid = 777 + self.assignment.save() + + api = self._api(host_get=[{'hostid': '777', 'host': 'binding-test', 'name': 'binding-test'}]) + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + with self.assertRaises(RuntimeError): + sync.sync() + + def test_decommission_deletes_by_hostid(self): + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=100, + hostname='binding-test', + ) + api = self._api(host_get=[{'hostid': '100', 'host': 'binding-test', 'name': 'binding-test'}]) + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + sync.delete() + + api.host.delete.assert_called_once_with([100]) + self.assertFalse(ZabbixHostBinding.objects.filter(assigned_object_id=self.device.pk, zabbixserver=self.server).exists()) + + def test_missing_remote_host_is_idempotent(self): + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=999, + hostname='binding-test', + ) + api = self._api() + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + sync.sync() + + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 100) + + def test_direct_assignment_hostid_migrates_to_binding(self): + self.assignment.hostid = 222 + self.assignment.save() + + def host_get(**kwargs): + if kwargs.get('hostids') == ['222']: + return [{'hostid': '222', 'host': 'binding-test', 'name': 'binding-test'}] + return [] + + api = MagicMock() + api.host.get.side_effect = host_get + api.host.create.return_value = {'hostids': ['222']} + api.host.update.return_value = {'hostids': ['222']} + api.hostinterface.get.return_value = [] + api.template.get.return_value = [] + api.maintenance.get.return_value = [] + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + sync.sync() + + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 222) + self.assignment.refresh_from_db() + self.assertIsNone(self.assignment.hostid) + + def test_existing_hosts_backfill(self): + self._set_plugin_setting('adopt_existing_hosts', True) + + def host_get(**kwargs): + if kwargs.get('hostids'): + return [] + if kwargs.get('filter', {}).get('host'): + return [ + { + 'hostid': '300', + 'host': 'binding-test', + 'name': 'binding-test', + 'tags': [ + {'tag': 'nb_type', 'value': 'device'}, + {'tag': 'nb_id', 'value': str(self.device.pk)}, + ], + } + ] + return [] + + api = MagicMock() + api.host.get.side_effect = host_get + api.hostinterface.get.return_value = [] + api.template.get.return_value = [] + api.maintenance.get.return_value = [] + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + sync.sync() + + binding = ZabbixHostBinding.objects.get(assigned_object_id=self.device.pk, zabbixserver=self.server) + self.assertEqual(binding.hostid, 300) + + def test_existing_host_is_not_adopted_by_default(self): + """Without adopt_existing_hosts, an unbound managed host is a reported conflict.""" + + def host_get(**kwargs): + if kwargs.get('hostids'): + return [] + if kwargs.get('filter', {}).get('host'): + return [ + { + 'hostid': '300', + 'host': 'binding-test', + 'name': 'binding-test', + 'tags': [ + {'tag': 'nb_type', 'value': 'device'}, + {'tag': 'nb_id', 'value': str(self.device.pk)}, + ], + } + ] + return [] + + api = MagicMock() + api.host.get.side_effect = host_get + api.hostinterface.get.return_value = [] + api.template.get.return_value = [] + api.maintenance.get.return_value = [] + self._attach_assigned_objects(self.assignment) + + sync = HostSync(api=api, netbox_obj=self.assignment, all_objects=self._all_objects()) + with self.assertRaises(RuntimeError) as context: + sync.sync() + + self.assertIn('adopt_existing_hosts', str(context.exception)) + self.assertFalse(ZabbixHostBinding.objects.filter(assigned_object_id=self.device.pk, zabbixserver=self.server).exists()) + api.host.create.assert_not_called() + + def test_hard_delete_after_netbox_object_is_missing(self): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=9_000_001, + hostid=4_000_000_001, + hostname='deleted-binding-test', + ) + proxy = HostBindingDeleteProxy(binding) + api = self._api(host_get=[{'hostid': str(binding.hostid), 'host': binding.hostname}]) + + HostSync(api=api, netbox_obj=proxy).delete() + + api.host.delete.assert_called_once_with([binding.hostid]) + self.assertFalse(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + def test_hard_delete_failure_retains_binding(self): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=9_000_002, + hostid=4_000_000_002, + hostname='retry-binding-test', + ) + proxy = HostBindingDeleteProxy(binding) + api = self._api(host_get=[{'hostid': str(binding.hostid), 'host': binding.hostname}]) + api.host.delete.side_effect = RuntimeError('temporary Zabbix failure') + + with self.assertRaises(RuntimeError): + HostSync(api=api, netbox_obj=proxy).delete() + + self.assertTrue(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + def test_hard_delete_missing_remote_is_idempotent(self): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=9_000_003, + hostid=4_000_000_003, + hostname='missing-binding-test', + ) + proxy = HostBindingDeleteProxy(binding) + api = self._api(host_get=[]) + + HostSync(api=api, netbox_obj=proxy).delete() + + api.host.delete.assert_not_called() + self.assertFalse(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + +class BindingJobTestCase(PluginSettingMixin, TestCase): + @classmethod + def setUpTestData(cls): + cls.server = ZabbixServer.objects.create(name='Zabbix', url='http://zabbix.local', token='t') + cls.device = create_test_device(name='binding-job-test') + cls.device_ct = ContentType.objects.get_for_model(cls.device) + + def test_deletejob_uses_stable_binding_id(self): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=888, + hostname='binding-job-test', + ) + + from nbxsync.jobs.deletehost import DeleteHostJob + + with patch('nbxsync.jobs.deletehost.safe_delete') as mock_safe_delete: + DeleteHostJob(binding_ids=[binding.pk]).run() + + self.assertEqual(mock_safe_delete.call_count, 1) + proxy = mock_safe_delete.call_args[0][1] + self.assertIsInstance(proxy, HostBindingDeleteProxy) + self.assertEqual(proxy.binding_id, binding.pk) + self.assertEqual(proxy.hostid, 888) + self.assertTrue(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + def test_deletejob_failure_keeps_binding_and_raises_for_retry(self): + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=889, + hostname='binding-job-test', + ) + + from nbxsync.jobs.deletehost import DeleteHostJob + + with patch('nbxsync.jobs.deletehost.safe_delete', side_effect=RuntimeError('temporary failure')): + with self.assertRaises(RuntimeError): + DeleteHostJob(binding_ids=[binding.pk]).run() + + self.assertTrue(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + def test_retire_unassigned_bindings(self): + self._set_plugin_setting('allow_inherited_deletion', True) + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=555, + hostname='binding-job-test', + ) + + from nbxsync.jobs.synchost import SyncHostJob + + with patch('nbxsync.jobs.synchost.safe_delete') as mock_safe_delete: + job = SyncHostJob(instance=self.device) + job._retire_unassigned_bindings(set()) + + mock_safe_delete.assert_called_once() + proxy = mock_safe_delete.call_args[0][1] + self.assertIsInstance(proxy, HostBindingDeleteProxy) + self.assertEqual(proxy.zabbixserver, self.server) + self.assertEqual(proxy.hostid, binding.hostid) + self.assertEqual(proxy.assigned_object, self.device) + + def test_unassigned_binding_is_kept_when_inherited_deletion_disabled(self): + """The default configuration reports the impact instead of deleting the host.""" + binding = ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=556, + hostname='binding-job-test', + ) + + from nbxsync.jobs.synchost import SyncHostJob + + with patch('nbxsync.jobs.synchost.safe_delete') as mock_safe_delete: + with self.assertLogs('nbxsync.jobs.synchost', level='WARNING') as logs: + job = SyncHostJob(instance=self.device) + job._retire_unassigned_bindings(set()) + + mock_safe_delete.assert_not_called() + self.assertIn('allow_inherited_deletion', ' '.join(logs.output)) + self.assertTrue(ZabbixHostBinding.objects.filter(pk=binding.pk).exists()) + + +class ManagedHostIdentityTestCase(TestCase): + """get_managed_host_id / iter_managed_hosts after assignment.hostid is cleared.""" + + @classmethod + def setUpTestData(cls): + cls.server = ZabbixServer.objects.create(name='Identity Zabbix', url='http://zabbix.local', token='t') + cls.disabled_server = ZabbixServer.objects.create(name='Disabled Zabbix', url='http://zabbix-off.local', token='t', sync_enabled=False) + cls.device = create_test_device(name='identity-test') + cls.device_ct = ContentType.objects.get_for_model(cls.device) + cls.assignment = ZabbixServerAssignment.objects.create( + zabbixserver=cls.server, + assigned_object_type=cls.device_ct, + assigned_object_id=cls.device.pk, + ) + + def test_get_managed_host_id_prefers_binding_over_cleared_assignment(self): + from nbxsync.utils.host_binding import get_managed_host_id + + self.assignment.hostid = None + self.assignment.save() + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=4242, + hostname='identity-test', + ) + + self.assertEqual(get_managed_host_id(self.device, self.server), 4242) + + def test_get_managed_host_id_falls_back_to_direct_assignment(self): + from nbxsync.utils.host_binding import get_managed_host_id + + self.assignment.hostid = 777 + self.assignment.save() + + self.assertEqual(get_managed_host_id(self.device, self.server), 777) + + def test_get_managed_host_id_ignores_inherited_assignment_hostid(self): + from dcim.models import Site + + from nbxsync.utils.host_binding import get_managed_host_id + + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + hostid=9999, + ) + self.assignment.delete() + + self.assertIsNone(get_managed_host_id(self.device, self.server)) + + def test_iter_managed_hosts_finds_inherited_binding(self): + from dcim.models import Site + + from nbxsync.utils.host_binding import iter_managed_hosts + + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + self.assignment.delete() + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + ) + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=8800, + hostname='identity-test', + ) + + managed = list(iter_managed_hosts(self.device, require_hostid=True)) + self.assertEqual(len(managed), 1) + self.assertEqual(managed[0].hostid, 8800) + self.assertEqual(managed[0].zabbixserver_id, self.server.pk) + + def test_iter_managed_hosts_skips_disabled_server(self): + from nbxsync.utils.host_binding import iter_managed_hosts + + ZabbixHostBinding.objects.create( + zabbixserver=self.disabled_server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=1, + ) + self.assertEqual(list(iter_managed_hosts(self.device, require_hostid=True)), []) + + def test_get_host_assignments_uses_binding_after_hostid_cleared(self): + from nbxsync.utils.trigger_dependency_sync import get_host_assignments + + self.assignment.hostid = None + self.assignment.save() + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=5150, + hostname='identity-test', + ) + + result = get_host_assignments(self.device) + self.assertEqual(result[self.server.pk].hostid, 5150) diff --git a/nbxsync/tests/utils/test_hostgroupsync.py b/nbxsync/tests/utils/test_hostgroupsync.py index f8df027e..3b142f08 100644 --- a/nbxsync/tests/utils/test_hostgroupsync.py +++ b/nbxsync/tests/utils/test_hostgroupsync.py @@ -89,3 +89,99 @@ def test_set_id_dynamic_creates_rendered_hostgroup_when_missing(self): self.assertEqual(created_hg.value, rendered_name) self.assertEqual(created_hg.groupid, 321) self.assertEqual(created_hg.description, 'Automatically generated from template') + + +class HostGroupSyncNestedTests(TestCase): + """Nested hostgroup names ('A/B/C') must materialize parents before the leaf. + + Zabbix only inherits user-group permissions/tag filters into a subgroup + when its parent already exists; subgroup-only creation leaves phantom + parents that can never hold permissions. + """ + + @classmethod + def setUpTestData(cls): + cls.zabbixserver = ZabbixServer.objects.create(name='Zabbix Nested', url='http://zabbix-nested.local', token='abc123', validate_certs=True) + cls.device_ct = ContentType.objects.get_for_model(Device) + cls.device = create_test_device(name='Nested Hostgroup TestDev') + + def _nested_assignment(self, name): + hostgroup = ZabbixHostgroup.objects.create(name=name, zabbixserver=self.zabbixserver, value=name) + return ZabbixHostgroupAssignment.objects.create(zabbixhostgroup=hostgroup, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) + + def test_try_create_nested_creates_parents_before_leaf(self): + from unittest.mock import call + + api = MagicMock() + api.hostgroup.get.return_value = [] + api.hostgroup.create.side_effect = [{'groupids': [10]}, {'groupids': [11]}, {'groupids': [12]}] + + sync = HostGroupSync(api=api, netbox_obj=self._nested_assignment('Parent/Child/Leaf')) + self.assertEqual(sync.try_create(), 12) + + self.assertEqual( + api.hostgroup.create.call_args_list, + [ + call({'name': 'Parent'}), + call({'name': 'Parent/Child'}), + call(name='Parent/Child/Leaf'), + ], + ) + + def test_try_create_nested_idempotent_when_parents_exist(self): + api = MagicMock() + api.hostgroup.get.return_value = [{'groupid': '10', 'name': 'Parent'}] + api.hostgroup.create.return_value = {'groupids': [12]} + + assignment = self._nested_assignment('Parent/Child') + sync = HostGroupSync(api=api, netbox_obj=assignment) + sync.try_create() + + # Parents found -> only the leaf is created + self.assertEqual(api.hostgroup.create.call_count, 1) + api.hostgroup.create.assert_called_once_with(name='Parent/Child') + + def test_try_create_flat_name_skips_parent_logic(self): + api = MagicMock() + api.hostgroup.create.return_value = {'groupids': [5]} + + assignment = self._nested_assignment('FlatGroup') + sync = HostGroupSync(api=api, netbox_obj=assignment) + sync.try_create() + + api.hostgroup.get.assert_not_called() + api.hostgroup.create.assert_called_once_with(name='FlatGroup') + + def test_try_create_malformed_name_leaves_rejection_to_zabbix(self): + """Empty segments / stray slashes are not our problem to fix silently: + we skip parent logic and let the Zabbix API reject the leaf create.""" + api = MagicMock() + api.hostgroup.create.side_effect = RuntimeError('invalid group name') + + assignment = self._nested_assignment('A//B') + sync = HostGroupSync(api=api, netbox_obj=assignment) + + with self.assertRaises(RuntimeError): + sync.try_create() + # No parents were probed/created for the malformed name + api.hostgroup.get.assert_not_called() + self.assertEqual(api.hostgroup.create.call_count, 1) + + +class HostGroupSyncRenameTests(TestCase): + """Renames must flow through hostgroup.update with the stored groupid.""" + + @classmethod + def setUpTestData(cls): + cls.device_ct = ContentType.objects.get_for_model(Device) + cls.device = create_test_device(name='Rename TestDev') + cls.zabbixserver = ZabbixServer.objects.create(name='Zabbix Rename', url='http://zabbix-rename.local', token='abc123', validate_certs=True) + cls.hostgroup = ZabbixHostgroup.objects.create(name='Static Group', groupid=123, zabbixserver=cls.zabbixserver, value='Static Group') + cls.assignment = ZabbixHostgroupAssignment.objects.create(zabbixhostgroup=cls.hostgroup, assigned_object_type=cls.device_ct, assigned_object_id=cls.device.id) + + def test_static_rename_updates_in_place_keeping_groupid(self): + self.hostgroup.value = 'Renamed Static Group' + self.hostgroup.name = 'Renamed Static Group' + self.hostgroup.save() + sync = HostGroupSync(api=MagicMock(), netbox_obj=self.assignment) + self.assertEqual(sync.get_update_params(), {'name': 'Renamed Static Group', 'groupid': 123}) diff --git a/nbxsync/tests/utils/test_hostinterfacesync.py b/nbxsync/tests/utils/test_hostinterfacesync.py index b3e99236..ae619ad0 100644 --- a/nbxsync/tests/utils/test_hostinterfacesync.py +++ b/nbxsync/tests/utils/test_hostinterfacesync.py @@ -35,6 +35,50 @@ def setUpTestData(cls): cls.assignment = ZabbixServerAssignment.objects.create(zabbixserver=cls.zabbixserver, hostid='10101', assigned_object_type=cls.device_ct, assigned_object_id=cls.device.id) + def test_get_create_params_uses_binding_when_assignment_hostid_cleared(self): + from nbxsync.models import ZabbixHostBinding + + self.assignment.hostid = None + self.assignment.save() + ZabbixHostBinding.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + hostid=4242, + ) + + sync = HostInterfaceSync(api=None, netbox_obj=self.hostinterface) + sync.context = {'_instance': self.device} + + params = sync.get_create_params() + self.assertEqual(params['hostid'], 4242) + + def test_find_by_name_requests_extend_and_numeric_type(self): + api = MagicMock() + api.hostinterface.get.return_value = [ + {'interfaceid': '10', 'type': '1', 'port': '10050', 'useip': '1', 'main': '1', 'ip': '10.1.1.1', 'dns': ''}, + ] + sync = HostInterfaceSync(api=api, netbox_obj=self.hostinterface) + sync.context = {'hostid': '10101'} + + found = sync.find_by_name() + + self.assertEqual([iface['interfaceid'] for iface in found], ['10']) + kwargs = api.hostinterface.get.call_args.kwargs + self.assertEqual(kwargs.get('output'), 'extend') + self.assertEqual(kwargs.get('hostids'), [10101]) + self.assertEqual(kwargs.get('filter'), {'type': str(int(ZabbixHostInterfaceTypeChoices.AGENT))}) + + def test_find_by_name_ignores_same_tuple_on_another_host(self): + api = MagicMock() + api.hostinterface.get.return_value = [ + {'interfaceid': '7777', 'hostid': '99999', 'type': '1', 'port': '10050', 'useip': '1', 'main': '1', 'ip': '10.1.1.1', 'dns': ''}, + ] + sync = HostInterfaceSync(api=api, netbox_obj=self.hostinterface) + sync.context = {'hostid': '10101'} + + self.assertEqual(sync.find_by_name(), []) + def test_get_create_params_basic(self): sync = HostInterfaceSync(api=None, netbox_obj=self.hostinterface) sync.context = {} diff --git a/nbxsync/tests/utils/test_hostsync.py b/nbxsync/tests/utils/test_hostsync.py index 5f2b193e..4adc37bb 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): @@ -413,7 +434,7 @@ class DummyAssignedTag: def __init__(self): self.zabbixtag = DummyZabbixTag('env') - def render(self): + def render(self, **kwargs): return ('production', True) dummy_tag = DummyAssignedTag() @@ -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 @@ -504,6 +525,31 @@ def fake_get(*args, **kwargs): self.assertEqual(result, {}) self.assertFalse(called['hostinterface_get']) + def test_verify_hostinterfaces_uses_binding_when_assignment_hostid_cleared(self): + from nbxsync.models import ZabbixHostBinding + + ZabbixHostBinding.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + hostid=12345, + ) + self.obj.hostid = None + called = {'hostinterface_get': False} + + def fake_get(*args, **kwargs): + called['hostinterface_get'] = True + self.assertEqual(str(kwargs.get('hostids')), '12345') + return [] + + self.sync.api.hostinterface.get = fake_get + self.sync.context['all_objects']['_instance'] = self.device + + self.sync.verify_hostinterfaces() + + self.assertTrue(called['hostinterface_get']) + self.assertEqual(int(self.obj.hostid), 12345) + def test_verify_hostinterfaces_deletes_unexpected_interfaces(self): self.obj.hostid = '12345' @@ -529,6 +575,40 @@ def mock_delete(interfaceid): # Only the unexpected one should be deleted self.assertEqual(deleted_ids, [2002]) + def test_verify_hostinterfaces_requests_output_extend(self): + """Transient IFs match by identity; output must be the string 'extend'.""" + self.obj.hostid = '12345' + self.interface_snmp.interfaceid = None + self.interface_snmp.interface_type = ZabbixInterfaceTypeChoices.DEFAULT + self.interface_snmp.useip = 1 + self.interface_snmp.port = 161 + self.interface_snmp.dns = '' + self.sync.context['all_objects']['hostinterfaces'] = [self.interface_snmp] + + get_kwargs = [] + + def fake_get(**kwargs): + get_kwargs.append(kwargs) + return [ + { + 'interfaceid': '900', + 'type': str(int(ZabbixHostInterfaceTypeChoices.SNMP)), + 'main': str(int(ZabbixInterfaceTypeChoices.DEFAULT)), + 'useip': '1', + 'port': '161', + 'dns': '', + }, + ] + + deleted = [] + self.sync.api.hostinterface.get = fake_get + self.sync.api.hostinterface.delete = lambda interfaceid: deleted.append(interfaceid) + + self.sync.verify_hostinterfaces() + + self.assertEqual(get_kwargs[0].get('output'), 'extend') + self.assertEqual(deleted, []) + def test_delete_raises_runtimeerror_on_api_failure(self): self.obj.hostid = '12345' @@ -554,3 +634,344 @@ def fake_update_sync_info(success, message): self.assertEqual(len(messages), 1) self.assertFalse(messages[0][0]) # success = False self.assertIn('Simulated API failure', messages[0][1]) + + def test_verify_hostinterfaces_skips_inherited_when_deletion_disabled(self): + self.obj.hostid = '12345' + self.obj._is_inherited_copy = True + self.sync.pluginsettings.allow_inherited_deletion = False + self.sync.context['all_objects']['hostinterfaces'] = [self.interface_agent] + self.interface_agent.interfaceid = 1001 + + deleted = [] + self.sync.api.hostinterface.get = lambda **kwargs: [ + {'interfaceid': 1001, 'type': 1, 'useip': 1, 'port': '10050'}, + {'interfaceid': 2002, 'type': 2, 'useip': 1, 'port': '161'}, + ] + self.sync.api.hostinterface.delete = lambda interfaceid: deleted.append(interfaceid) + + self.sync.verify_hostinterfaces() + self.assertEqual(deleted, []) + + def test_verify_hostinterfaces_deletes_stale_for_inherited_when_enabled(self): + self.obj.hostid = '12345' + self.obj._is_inherited_copy = True + self.sync.pluginsettings.allow_inherited_deletion = True + self.interface_agent.interfaceid = 1001 + self.sync.context['all_objects']['hostinterfaces'] = [self.interface_agent] + + deleted = [] + self.sync.api.hostinterface.get = lambda **kwargs: [ + {'interfaceid': 1001, 'type': 1, 'useip': 1, 'port': '10050'}, + {'interfaceid': 2002, 'type': 2, 'useip': 1, 'port': '161'}, + ] + self.sync.api.hostinterface.delete = lambda interfaceid: deleted.append(interfaceid) + + self.sync.verify_hostinterfaces() + self.assertEqual(deleted, [2002]) + + def test_get_groups_raises_when_render_fails(self): + class BrokenGroup: + zabbixhostgroup = type('HG', (), {'groupid': None})() + + def render(self, object=None): + raise RuntimeError('jinja boom') + + self.obj.assigned_objects['hostgroups'] = [BrokenGroup()] + + with self.assertRaises(RuntimeError) as context: + self.sync.get_groups() + + self.assertIn('jinja boom', str(context.exception)) + + def test_check_default_does_not_insert_inherited_clone(self): + from nbxsync.models import ZabbixHostInterface as HI + + self.obj.hostid = '12345' + clone = HI( + zabbixserver=self.zabbixserver, + type=ZabbixHostInterfaceTypeChoices.AGENT, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + useip=ZabbixInterfaceUseChoices.IP, + port=10050, + ip=self.ip, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + clone.pk = None + clone._is_inherited_copy = True + self.sync.context['all_objects']['hostinterfaces'] = [clone] + + self.sync.api.hostinterface.get = lambda **kwargs: [{'interfaceid': '999', 'type': int(ZabbixHostInterfaceTypeChoices.AGENT), 'main': 1}] + self.sync.api.hostinterface.create = lambda **params: {'interfaceids': ['555']} + self.sync.api.host.update = lambda **kwargs: {} + + before = HI.objects.count() + self.sync.check_default_hostinterface() + self.assertEqual(HI.objects.count(), before) + self.assertEqual(clone.interfaceid, 555) + + def test_check_default_reuses_existing_remote_interface(self): + clone = ZabbixHostInterface( + zabbixserver=self.zabbixserver, + type=ZabbixHostInterfaceTypeChoices.AGENT, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + useip=ZabbixInterfaceUseChoices.IP, + port=10050, + ip=self.ip, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + ) + clone.pk = None + clone._is_inherited_copy = True + self.sync.context['all_objects']['hostinterfaces'] = [clone] + + self.sync.api.hostinterface.get = lambda **kwargs: [ + { + 'interfaceid': '999', + 'type': int(ZabbixHostInterfaceTypeChoices.AGENT), + 'main': 1, + 'useip': int(ZabbixInterfaceUseChoices.IP), + 'ip': str(self.ip.address).split('/')[0], + 'port': '10050', + } + ] + creates = [] + updates = [] + self.sync.api.hostinterface.create = lambda **params: creates.append(params) + self.sync.api.host.update = lambda **kwargs: updates.append(kwargs) + + self.sync.check_default_hostinterface() + + self.assertEqual(creates, []) + self.assertEqual(clone.interfaceid, 999) + self.assertEqual(updates[0]['interfaces'][0]['interfaceid'], 999) + + +class HostSyncFirstOfTypeDefaultTests(TestCase): + """Production matrix: inherited Agent+SNMP defaults, remote Agent only. + + Creating the first SNMP interface as main=0 is invalid. Zabbix permits + Agent main=1 and SNMP main=1 on the same host; another type already + having a default must not force a non-default create. + """ + + def setUp(self): + self.device = create_test_device(name='STA-ME05') + self.device_ct = ContentType.objects.get_for_model(Device) + self.zabbixserver = ZabbixServer.objects.create(name='Zabbix FirstOfType', url='http://example.com', token='dummy-token', validate_certs=True) + self.ip = IPAddress.objects.create(address='10.0.109.60/24') + + class DummyHost: + def __init__(self, device, device_ct, server): + self.assigned_object = device + self.assigned_object_id = device.id + self.assigned_object_type = device_ct + self.hostid = '14030' + self.zabbixserver = server + self.zabbixproxy = None + self.zabbixproxygroup = None + self.assigned_objects = {'hostgroups': []} + + def save(self): + pass + + def update_sync_info(self, success, message): + pass + + class DummyAPI: + def __init__(self): + self.host = self.Host() + self.hostinterface = self.HostInterface() + + class Host: + def update(self, **kwargs): + return {} + + class HostInterface: + def get(self, **kwargs): + return [] + + def create(self, **params): + return {'interfaceids': ['9000']} + + self.obj = DummyHost(self.device, self.device_ct, self.zabbixserver) + self.sync = HostSync(api=DummyAPI(), netbox_obj=self.obj, obj=self.obj) + self.sync.context = {'all_objects': {'hostinterfaces': [], '_instance': self.device}} + self.sync.pluginsettings = self._pluginsettings() + self.remote = [] + self.creates = [] + self.updates = [] + self.sync.api.hostinterface.get = self._get + self.sync.api.hostinterface.create = self._create + self.sync.api.host.update = self._update + + def _pluginsettings(self): + class PluginSettings: + class StatusMapping: + device = {'active': ZabbixHostStatus.ENABLED} + + statusmapping = StatusMapping() + + class SNMPConfig: + snmp_community = '{$SNMP_COMMUNITY}' + snmp_authpass = '{$SNMP_AUTHPASS}' + snmp_privpass = '{$SNMP_PRIVPASS}' + + snmpconfig = SNMPConfig() + + class SOT: + hostmacro = SyncSOT.ZABBIX + hosttemplate = SyncSOT.NETBOX + hostinterface = SyncSOT.NETBOX + host = SyncSOT.NETBOX + + sot = SOT() + attach_objtag = True + objtag_type = 'nb_type' + objtag_id = 'nb_id' + allow_inherited_deletion = False + + return PluginSettings() + + def _inherited(self, interface_type, port, **kwargs): + clone = ZabbixHostInterface( + zabbixserver=self.zabbixserver, + type=interface_type, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + useip=ZabbixInterfaceUseChoices.IP, + port=port, + ip=self.ip, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.id, + **kwargs, + ) + clone.pk = None + clone.interfaceid = None + clone._is_inherited_copy = True + return clone + + def _remote_iface(self, interfaceid, itype, port, main=1, hostid='14030', **extra): + row = { + 'interfaceid': str(interfaceid), + 'hostid': str(hostid), + 'type': int(itype), + 'main': int(main), + 'useip': 1, + 'ip': '10.0.109.60', + 'port': str(port), + 'dns': '', + } + row.update(extra) + return row + + def _get(self, **kwargs): + ifaces = list(self.remote) + wanted = (kwargs.get('filter') or {}).get('type') + if wanted is not None: + ifaces = [iface for iface in ifaces if str(int(iface['type'])) == str(int(wanted))] + return ifaces + + def _create(self, **params): + if int(params.get('type', 0)) == int(ZabbixHostInterfaceTypeChoices.SNMP) and int(params.get('main', 1)) == 0: + raise RuntimeError('No default interface for "SNMP" type on "CH-STA-D-ME05".') + self.creates.append(params) + new_id = str(9000 + len(self.creates)) + self.remote.append( + self._remote_iface( + new_id, + params.get('type'), + params.get('port', '0'), + main=params.get('main', 1), + **{'ip': params.get('ip', '10.0.109.60')}, + ) + ) + return {'interfaceids': [new_id]} + + def _update(self, **kwargs): + self.updates.append(kwargs) + return {} + + def test_inherited_snmp_absent_remotely_is_created_as_main(self): + agent = self._inherited(ZabbixHostInterfaceTypeChoices.AGENT, 10050) + snmp = self._inherited( + ZabbixHostInterfaceTypeChoices.SNMP, + 161, + snmp_version=ZabbixHostInterfaceSNMPVersionChoices.SNMPV3, + snmp_usebulk=True, + ) + self.sync.context['all_objects']['hostinterfaces'] = [agent, snmp] + self.remote = [self._remote_iface(3285, ZabbixHostInterfaceTypeChoices.AGENT, 10050)] + + before = ZabbixHostInterface.objects.count() + self.sync.check_default_hostinterface() + + snmp_creates = [params for params in self.creates if int(params['type']) == int(ZabbixHostInterfaceTypeChoices.SNMP)] + self.assertEqual(len(snmp_creates), 1) + self.assertEqual(int(snmp_creates[0]['main']), 1) + self.assertEqual(int(snmp_creates[0]['type']), int(ZabbixHostInterfaceTypeChoices.SNMP)) + self.assertEqual(agent.interfaceid, 3285) + self.assertIsNotNone(snmp.interfaceid) + self.assertEqual(ZabbixHostInterface.objects.count(), before) + + def test_agent_and_snmp_defaults_coexist(self): + agent = self._inherited(ZabbixHostInterfaceTypeChoices.AGENT, 10050) + snmp = self._inherited(ZabbixHostInterfaceTypeChoices.SNMP, 161, snmp_version=ZabbixHostInterfaceSNMPVersionChoices.SNMPV2) + self.sync.context['all_objects']['hostinterfaces'] = [agent, snmp] + self.remote = [ + self._remote_iface(3285, ZabbixHostInterfaceTypeChoices.AGENT, 10050), + self._remote_iface(4001, ZabbixHostInterfaceTypeChoices.SNMP, 161), + ] + + self.sync.check_default_hostinterface() + + self.assertEqual(self.creates, []) + self.assertEqual(agent.interfaceid, 3285) + self.assertEqual(snmp.interfaceid, 4001) + + def test_foreign_host_snmp_default_does_not_force_nonstrict_create(self): + """A default SNMP id from another host must not make this host create main=0.""" + agent = self._inherited(ZabbixHostInterfaceTypeChoices.AGENT, 10050) + snmp = self._inherited(ZabbixHostInterfaceTypeChoices.SNMP, 161, snmp_version=ZabbixHostInterfaceSNMPVersionChoices.SNMPV2) + self.sync.context['all_objects']['hostinterfaces'] = [agent, snmp] + self.remote = [ + self._remote_iface(3285, ZabbixHostInterfaceTypeChoices.AGENT, 10050, hostid='14030'), + self._remote_iface(7777, ZabbixHostInterfaceTypeChoices.SNMP, 161, hostid='99999'), + ] + + self.sync.check_default_hostinterface() + + snmp_creates = [params for params in self.creates if int(params['type']) == int(ZabbixHostInterfaceTypeChoices.SNMP)] + self.assertEqual(len(snmp_creates), 1) + self.assertEqual(int(snmp_creates[0]['main']), 1) + self.assertNotEqual(snmp.interfaceid, 7777) + + def test_existing_nonstrict_snmp_is_promoted_not_duplicated(self): + snmp = self._inherited(ZabbixHostInterfaceTypeChoices.SNMP, 161, snmp_version=ZabbixHostInterfaceSNMPVersionChoices.SNMPV2) + self.sync.context['all_objects']['hostinterfaces'] = [snmp] + self.remote = [self._remote_iface(4001, ZabbixHostInterfaceTypeChoices.SNMP, 161, main=0)] + + self.sync.check_default_hostinterface() + + self.assertEqual(self.creates, []) + self.assertEqual(snmp.interfaceid, 4001) + self.assertTrue(self.updates) + flipped = self.updates[0]['interfaces'][0] + self.assertEqual(int(flipped['interfaceid']), 4001) + self.assertEqual(int(flipped['main']), int(ZabbixInterfaceTypeChoices.DEFAULT)) + + def test_inventory_uses_extend_and_hostid_list(self): + seen = [] + + def capture_get(**kwargs): + seen.append(kwargs) + return [self._remote_iface(3285, ZabbixHostInterfaceTypeChoices.AGENT, 10050)] + + agent = self._inherited(ZabbixHostInterfaceTypeChoices.AGENT, 10050) + self.sync.context['all_objects']['hostinterfaces'] = [agent] + self.sync.api.hostinterface.get = capture_get + + self.sync.check_default_hostinterface() + + inventory = seen[0] + self.assertEqual(inventory.get('output'), 'extend') + self.assertEqual(inventory.get('hostids'), [14030]) diff --git a/nbxsync/tests/utils/test_inheritance_recursive.py b/nbxsync/tests/utils/test_inheritance_recursive.py new file mode 100644 index 00000000..b0ff8e17 --- /dev/null +++ b/nbxsync/tests/utils/test_inheritance_recursive.py @@ -0,0 +1,177 @@ +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_inheritance_targets.py b/nbxsync/tests/utils/test_inheritance_targets.py new file mode 100644 index 00000000..831114f1 --- /dev/null +++ b/nbxsync/tests/utils/test_inheritance_targets.py @@ -0,0 +1,207 @@ +"""Tests for hierarchy assignment targets and ConfigGroup interface expansion. + +Covers the parts of the inheritance resolver that the assignment surfaces +advertise but the models used to reject or collapse: Region as an assignment +target, hierarchy-level host inventory, and ConfigGroup interfaces that share a +Zabbix type but describe different endpoints. +""" + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from ipam.models import IPAddress + +from dcim.models import Region, Site, SiteGroup +from utilities.testing import create_test_device + +from nbxsync.choices import ZabbixHostInterfaceTypeChoices, ZabbixInterfaceTypeChoices, ZabbixInterfaceUseChoices +from nbxsync.models import ( + ZabbixConfigurationGroup, + ZabbixConfigurationGroupAssignment, + ZabbixHostInterface, + ZabbixHostInventory, + ZabbixServer, + ZabbixTemplate, + ZabbixTemplateAssignment, +) +from nbxsync.utils.inheritance import get_assigned_zabbixobjects + + +class RegionAssignmentTargetTestCase(TestCase): + """Region is offered by the forms, documented as inherited, and must be storable.""" + + def setUp(self): + self.server = ZabbixServer.objects.create(name='Region Server', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Linux by Zabbix agent', zabbixserver=self.server, templateid=10001) + self.region = Region.objects.create(name='EMEA', slug='emea') + self.child_region = Region.objects.create(name='Netherlands', slug='nl', parent=self.region) + self.site = Site.objects.create(name='Amsterdam', slug='ams', region=self.child_region) + self.device = create_test_device(name='region-device', site=self.site) + + def test_region_is_an_allowed_assignment_content_type(self): + region_ct = ContentType.objects.get_for_model(Region) + allowed = ContentType.objects.filter(ZabbixTemplateAssignment._meta.get_field('assigned_object_type').get_limit_choices_to()) + + self.assertIn(region_ct, allowed) + + def test_template_assigned_to_parent_region_is_inherited(self): + assignment = ZabbixTemplateAssignment.objects.create( + zabbixtemplate=self.template, + assigned_object_type=ContentType.objects.get_for_model(Region), + assigned_object_id=self.region.pk, + ) + assignment.full_clean() + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual([obj.zabbixtemplate_id for obj in result['templates']], [self.template.pk]) + + def test_hostinventory_can_be_assigned_to_a_hierarchy_object(self): + inventory = ZabbixHostInventory( + assigned_object_type=ContentType.objects.get_for_model(SiteGroup), + assigned_object_id=SiteGroup.objects.create(name='Datacenters', slug='dcs').pk, + location='Rack row A', + ) + + inventory.full_clean() + inventory.save() + + self.assertTrue(ZabbixHostInventory.objects.filter(pk=inventory.pk).exists()) + + +class ConfigGroupInterfaceExpansionTestCase(TestCase): + """ConfigGroup interfaces are cloned per device and must not collapse by type.""" + + def setUp(self): + self.server = ZabbixServer.objects.create(name='CG Server', url='http://zabbix.local', token='abc123', validate_certs=True) + self.device = create_test_device(name='cg-device') + self.configgroup = ZabbixConfigurationGroup.objects.create(name='Standard SNMP') + ZabbixConfigurationGroupAssignment.objects.create( + zabbixconfigurationgroup=self.configgroup, + assigned_object_type=ContentType.objects.get_for_model(type(self.device)), + assigned_object_id=self.device.pk, + ) + + def _cg_interface(self, port, interface_type=ZabbixInterfaceTypeChoices.DEFAULT): + return ZabbixHostInterface.objects.create( + zabbixserver=self.server, + type=ZabbixHostInterfaceTypeChoices.SNMP, + useip=ZabbixInterfaceUseChoices.IP, + interface_type=interface_type, + port=port, + assigned_object_type=ContentType.objects.get_for_model(ZabbixConfigurationGroup), + assigned_object_id=self.configgroup.pk, + ) + + def test_inherited_agent_and_snmp_copies_do_not_persist_source_ids(self): + """Per-VM ConfigGroup copies must not write interfaceid back to SAP Agent+SNMP.""" + cg_ct = ContentType.objects.get_for_model(ZabbixConfigurationGroup) + agent_src = ZabbixHostInterface.objects.create( + zabbixserver=self.server, + type=ZabbixHostInterfaceTypeChoices.AGENT, + useip=ZabbixInterfaceUseChoices.IP, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + port=10050, + assigned_object_type=cg_ct, + assigned_object_id=self.configgroup.pk, + ) + snmp_src = self._cg_interface(port=161) + + result = get_assigned_zabbixobjects(self.device) + copies = result['hostinterfaces'] + self.assertEqual({int(interface.type) for interface in copies}, {int(ZabbixHostInterfaceTypeChoices.AGENT), int(ZabbixHostInterfaceTypeChoices.SNMP)}) + self.assertTrue(all(getattr(interface, '_is_inherited_copy', False) for interface in copies)) + self.assertTrue(all(interface.pk is None for interface in copies)) + self.assertTrue(all(interface.interfaceid is None for interface in copies)) + self.assertEqual( + ZabbixHostInterface.objects.filter( + assigned_object_type=ContentType.objects.get_for_model(type(self.device)), + assigned_object_id=self.device.pk, + ).count(), + 0, + ) + + agent_src.refresh_from_db() + snmp_src.refresh_from_db() + self.assertIsNone(agent_src.interfaceid) + self.assertIsNone(snmp_src.interfaceid) + + def test_two_snmp_interfaces_on_different_ports_are_both_expanded(self): + self._cg_interface(port=161) + self._cg_interface(port=1161, interface_type=ZabbixInterfaceTypeChoices.NOTDEFAULT) + + result = get_assigned_zabbixobjects(self.device) + + ports = sorted(interface.port for interface in result['hostinterfaces']) + self.assertEqual(ports, [161, 1161]) + + def test_configgroup_interface_yields_to_an_identical_direct_interface(self): + self._cg_interface(port=161) + ip = IPAddress.objects.create(address='192.0.2.10/24') + direct = ZabbixHostInterface.objects.create( + zabbixserver=self.server, + type=ZabbixHostInterfaceTypeChoices.SNMP, + useip=ZabbixInterfaceUseChoices.IP, + interface_type=ZabbixInterfaceTypeChoices.DEFAULT, + port=161, + ip=ip, + assigned_object_type=ContentType.objects.get_for_model(type(self.device)), + assigned_object_id=self.device.pk, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual([interface.pk for interface in result['hostinterfaces']], [direct.pk]) + + def test_configgroup_interfaces_are_filtered_by_zabbixserver(self): + other = ZabbixServer.objects.create(name='Other CG Server', url='http://other.local', token='xyz', validate_certs=True) + self._cg_interface(port=161) + ZabbixHostInterface.objects.create( + zabbixserver=other, + type=ZabbixHostInterfaceTypeChoices.SNMP, + useip=ZabbixInterfaceUseChoices.IP, + interface_type=ZabbixInterfaceTypeChoices.NOTDEFAULT, + port=1161, + assigned_object_type=ContentType.objects.get_for_model(ZabbixConfigurationGroup), + assigned_object_id=self.configgroup.pk, + ) + + result = get_assigned_zabbixobjects(self.device, zabbixserver=self.server) + + ports = [interface.port for interface in result['hostinterfaces']] + self.assertEqual(ports, [161]) + + +class VirtualMachineDeviceLeakTestCase(TestCase): + """A VM linked to its hosting device (NetBox 4.3+) must not inherit the + host's hardware-tier assignments (manufacturer, device_type, role via + 'device'-prefixed chain paths). A guest is not the hypervisor's hardware. + """ + + def setUp(self): + from virtualization.models import VirtualMachine + + from dcim.models import Manufacturer + + self.server = ZabbixServer.objects.create(name='Leak Server', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template = ZabbixTemplate.objects.create(name='Dell iDRAC by SNMP', zabbixserver=self.server, templateid=10255) + self.dell = Manufacturer.objects.create(name='Dell', slug='dell') + 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) + + manufacturer_ct = ContentType.objects.get_for_model(Manufacturer) + self.assignment = ZabbixTemplateAssignment.objects.create( + zabbixtemplate=self.template, + assigned_object_type=manufacturer_ct, + 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_maintenancesync.py b/nbxsync/tests/utils/test_maintenancesync.py index 5ec4b81e..f707083e 100644 --- a/nbxsync/tests/utils/test_maintenancesync.py +++ b/nbxsync/tests/utils/test_maintenancesync.py @@ -9,7 +9,7 @@ from utilities.testing import create_test_device from nbxsync.choices import ZabbixMaintenanceTypeChoices, ZabbixTimePeriodTypeChoices, ZabbixTimePeriodDayofWeekChoices, ZabbixTimePeriodMonthChoices, ZabbixMaintenanceTagOperatorChoices -from nbxsync.models import ZabbixHostgroup, ZabbixMaintenance, ZabbixMaintenanceObjectAssignment, ZabbixMaintenancePeriod, ZabbixMaintenanceTagAssignment, ZabbixServer, ZabbixServerAssignment, ZabbixTag +from nbxsync.models import ZabbixHostBinding, ZabbixHostgroup, ZabbixMaintenance, ZabbixMaintenanceObjectAssignment, ZabbixMaintenancePeriod, ZabbixMaintenanceTagAssignment, ZabbixServer, ZabbixServerAssignment, ZabbixTag from nbxsync.utils.sync.maintenancesync import MaintenanceSync @@ -207,6 +207,21 @@ def test_get_hosts_skips_null_hostid(self): self.assertEqual(result, []) + def test_get_hosts_uses_binding_when_assignment_hostid_cleared(self): + """HostSync migrates identity to ZabbixHostBinding and clears assignment.hostid.""" + ZabbixServerAssignment.objects.create(zabbixserver=self.server, assigned_object_type=self.device_ct, assigned_object_id=self.device.pk, hostid=None) + ZabbixHostBinding.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + hostid=202, + hostname=self.device.name, + ) + ZabbixMaintenanceObjectAssignment.objects.create(zabbixmaintenance=self.mw, assigned_object_type=self.device_ct, assigned_object_id=self.device.pk) + sync = MaintenanceSync(api=MagicMock(), netbox_obj=self.mw) + + self.assertEqual(sync.get_hosts(), [{'hostid': 202}]) + class MaintenanceSyncGetHostgroupsTestCase(TestCase): @classmethod 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/utils/test_trigger_dependency_sync.py b/nbxsync/tests/utils/test_trigger_dependency_sync.py index 3c74d4d5..b4573c86 100644 --- a/nbxsync/tests/utils/test_trigger_dependency_sync.py +++ b/nbxsync/tests/utils/test_trigger_dependency_sync.py @@ -417,21 +417,16 @@ def test_dependency_level_and_managed_descriptions_use_default_settings(self, mo self.assertEqual(get_dependency_level(unsupported), (None, None)) self.assertEqual(get_managed_trigger_descriptions(), {level.trigger_description for level in self.trigger_config.levels}) - @patch('nbxsync.utils.trigger_dependency_sync.ZabbixServerAssignment.objects') - @patch('nbxsync.utils.trigger_dependency_sync.ContentType.objects.get_for_model') - def test_get_server_assignments_filters_and_selects_server(self, mock_get_content_type, mock_assignment_objects): + @patch('nbxsync.utils.trigger_dependency_sync.iter_managed_hosts') + def test_get_server_assignments_uses_managed_hosts(self, mock_iter_managed_hosts): device = SimpleNamespace(pk=42) - content_type = SimpleNamespace(pk=7) - assignment = SimpleNamespace(hostid='1001') - mock_get_content_type.return_value = content_type - queryset = mock_assignment_objects.filter.return_value - queryset.select_related.return_value = [assignment] + assignment = SimpleNamespace(hostid='1001', zabbixserver_id=7) + mock_iter_managed_hosts.return_value = [assignment] result = get_server_assignments(device) self.assertEqual(result, [assignment]) - mock_assignment_objects.filter.assert_called_once_with(assigned_object_type=content_type, assigned_object_id=42, sync_enabled=True, zabbixserver__sync_enabled=True) - queryset.select_related.assert_called_once_with('zabbixserver') + mock_iter_managed_hosts.assert_called_once_with(device, require_hostid=False) @patch('nbxsync.utils.trigger_dependency_sync.get_server_assignments') def test_get_host_assignments_skips_missing_hostids_and_keys_by_server(self, mock_get_server_assignments): diff --git a/nbxsync/tests/utils/test_zabbixtemplaterule.py b/nbxsync/tests/utils/test_zabbixtemplaterule.py new file mode 100644 index 00000000..85ceeaef --- /dev/null +++ b/nbxsync/tests/utils/test_zabbixtemplaterule.py @@ -0,0 +1,167 @@ +from unittest.mock import patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Platform +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixServer, ZabbixTemplate, ZabbixTemplateAssignment, ZabbixTemplateRule +from nbxsync.utils.inheritance import get_assigned_zabbixobjects + + +class ZabbixTemplateRuleTestCase(TestCase): + def setUp(self): + self.device = create_test_device(name='TestDev') + self.server = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template_windows = ZabbixTemplate.objects.create(name='Windows by Zabbix agent', zabbixserver=self.server, templateid=10081) + self.template_linux = ZabbixTemplate.objects.create(name='Linux by Zabbix agent', zabbixserver=self.server, templateid=10001) + self.platform_ct = ContentType.objects.get_for_model(Platform) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_regex_rule_matches_platform_name(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022 (Build 20348.5400)', slug='win-2022-5400') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + ) + + result = get_assigned_zabbixobjects(self.device) + + templates = result['templates'] + self.assertEqual(len(templates), 1) + self.assertEqual(templates[0].zabbixtemplate, self.template_windows) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_regex_rule_does_not_match_wrong_platform(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Ubuntu 24.04 LTS', slug='ubuntu-2404') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_multiple_rules_match_correctly(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + win_platform = Platform.objects.create(name='Windows Server 2019', slug='win2019') + linux_platform = Platform.objects.create(name='Ubuntu 22.04 LTS', slug='ubuntu2204') + + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + ZabbixTemplateRule.objects.create(name='Linux', pattern='Ubuntu|Debian|Red Hat|CentOS', zabbixtemplate=self.template_linux) + + # Windows device + self.device.platform = win_platform + self.device.save() + result = get_assigned_zabbixobjects(self.device) + self.assertEqual(len(result['templates']), 1) + self.assertEqual(result['templates'][0].zabbixtemplate, self.template_windows) + + # Linux device + device2 = create_test_device(name='TestDev2') + device2.platform = linux_platform + device2.save() + result2 = get_assigned_zabbixobjects(device2) + self.assertEqual(len(result2['templates']), 1) + self.assertEqual(result2['templates'][0].zabbixtemplate, self.template_linux) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_direct_assignment_overrides_regex_rule(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022', slug='win2022') + self.device.platform = platform + self.device.save() + + # Direct assignment on the device + ZabbixTemplateAssignment.objects.create( + zabbixtemplate=self.template_linux, + assigned_object_type=ContentType.objects.get_for_model(type(self.device)), + assigned_object_id=self.device.pk, + ) + + # Regex rule that would match Windows + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + + result = get_assigned_zabbixobjects(self.device) + + # Direct assignment (template_linux) + regex rule (template_windows) + # Both should appear because they are different templates + # but direct assignment prevents the SAME template from being added by regex + templates = result['templates'] + self.assertEqual(len(templates), 2) + template_ids = {t.zabbixtemplate_id for t in templates} + self.assertEqual(template_ids, {self.template_linux.id, self.template_windows.id}) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_disabled_rule_ignored(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022', slug='win2022') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + enabled=False, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_case_insensitive_matching(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='UBUNTU 24.04 LTS', slug='ubuntu-2404') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create(name='Linux', pattern='ubuntu', zabbixtemplate=self.template_linux) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['templates']), 1) + self.assertEqual(result['templates'][0].zabbixtemplate, self.template_linux) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_device_without_platform_no_rules(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_rule_priority_ordering(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Ubuntu Hybrid', slug='hybrid') + self.device.platform = platform + self.device.save() + + # Both rules match, but Windows has lower priority value (higher priority) + ZabbixTemplateRule.objects.create(name='Linux', pattern='Ubuntu', zabbixtemplate=self.template_linux, priority=200) + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows, priority=100) + + result = get_assigned_zabbixobjects(self.device) + + # Both should match (different templates) + templates = result['templates'] + self.assertEqual(len(templates), 2) + template_ids = {t.zabbixtemplate_id for t in templates} + self.assertEqual(template_ids, {self.template_windows.id, self.template_linux.id}) diff --git a/nbxsync/tests/views/test_hostinfo.py b/nbxsync/tests/views/test_hostinfo.py index 3cd2a700..08a40117 100644 --- a/nbxsync/tests/views/test_hostinfo.py +++ b/nbxsync/tests/views/test_hostinfo.py @@ -60,3 +60,45 @@ def test_open_event_after_recovered_event_uses_own_state(self, mock_conn): open_row = next(r for r in rows if r['eventid'] == '3') self.assertIsNone(open_row['end_time']) self.assertIsNone(open_row['duration']) + + +class HostInfoBindingIdentityTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.device = create_test_device(name='dev-binding-ops') + cls.server = ZabbixServer.objects.create(name='ZBX-bind', url='http://example.com', token='test') + cls.assignment = ZabbixServerAssignment.objects.create(zabbixserver=cls.server, hostid=None, assigned_object_type=ContentType.objects.get_for_model(Device), assigned_object_id=cls.device.pk) + + from nbxsync.models import ZabbixHostBinding + + ZabbixHostBinding.objects.create( + zabbixserver=cls.server, + assigned_object_type=ContentType.objects.get_for_model(Device), + assigned_object_id=cls.device.pk, + hostid=202, + hostname=cls.device.name, + ) + + @patch('nbxsync.views.hostinfo.ZabbixConnection') + def test_events_use_binding_when_assignment_hostid_cleared(self, mock_conn): + api = MagicMock() + api.event.get.return_value = [_make_event('1', '1000')] + mock_conn.return_value.__enter__.return_value = api + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) + self.assertEqual(response.status_code, 200) + rows = list(response.context['table'].rows) + self.assertEqual(len(rows), 1) + api.event.get.assert_called() + self.assertEqual(api.event.get.call_args.kwargs['hostids'], 202) + + @patch('nbxsync.views.hostinfo.ZabbixConnection') + def test_problems_use_binding_when_assignment_hostid_cleared(self, mock_conn): + api = MagicMock() + api.problem.get.return_value = [] + mock_conn.return_value.__enter__.return_value = api + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_problems', kwargs={'objtype': 'device', 'pk': self.device.pk})) + self.assertEqual(response.status_code, 200) + api.problem.get.assert_called_once() + self.assertEqual(api.problem.get.call_args.kwargs['hostids'], 202) diff --git a/nbxsync/tests/views/test_jobs.py b/nbxsync/tests/views/test_jobs.py index 1c098c91..c8c2f73a 100644 --- a/nbxsync/tests/views/test_jobs.py +++ b/nbxsync/tests/views/test_jobs.py @@ -46,7 +46,7 @@ def setUp(self): interface_requirements=[HostInterfaceRequirementChoices.AGENT, HostInterfaceRequirementChoices.ANY], ) - def _run_sync_view_test(self, urlname, kwargs, expected_obj, job_func, message_snippet, expected_return=204): + def _run_sync_view_test(self, urlname, kwargs, expected_obj, job_func, message_snippet, expected_return=204, expected_args=None): url = reverse(f'plugins:nbxsync:{urlname}', kwargs=kwargs) with patch('nbxsync.views.jobs.get_queue') as mock_get_queue: mock_queue = mock_get_queue.return_value @@ -57,7 +57,11 @@ def _run_sync_view_test(self, urlname, kwargs, expected_obj, job_func, message_s self.assertEqual(response.status_code, expected_return) - mock_queue.create_job.assert_called_once_with(func=job_func, args=[expected_obj], timeout=9000) + mock_queue.create_job.assert_called_once_with( + func=job_func, + args=expected_args or [expected_obj], + timeout=9000, + ) mock_queue.enqueue_job.assert_called_once_with(mock_job) messages = list(get_messages(response.wsgi_request)) @@ -70,6 +74,7 @@ def test_enqueue_host_sync_job(self): expected_obj=self.device, job_func='nbxsync.worker.synchost', message_snippet='Sync job enqueued', + expected_args=[self.device._meta.app_label, self.device._meta.model_name, self.device.pk], ) def test_invalid_host_objtype_raises_404(self): diff --git a/nbxsync/tests/views/test_zabbixhostgroup.py b/nbxsync/tests/views/test_zabbixhostgroup.py index 7751a160..f0ceeb6e 100644 --- a/nbxsync/tests/views/test_zabbixhostgroup.py +++ b/nbxsync/tests/views/test_zabbixhostgroup.py @@ -5,9 +5,9 @@ from utilities.testing import ViewTestCases, create_test_device, create_test_user -from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixServer +from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixServer, ZabbixTemplate, ZabbixTemplateRule from nbxsync.views import ZabbixHostgroupView -from nbxsync.tables import ZabbixHostgroupObjectViewTable +from nbxsync.tables import ZabbixHostgroupObjectViewTable, ZabbixTemplateRuleHostgroupViewTable class ZabbixHostgroupTestCase( @@ -129,3 +129,36 @@ def test_get_extra_context_builds_and_configures_real_table_with_no_objects(self table = context.get('hostgroupassignment_table') self.assertIsNone(table) + self.assertIsNone(context.get('templaterule_table')) + + def test_get_extra_context_shows_template_rules_without_assignments(self): + """OS/*-style groups: no HostgroupAssignment, but TemplateRule.zabbixhostgroup set.""" + user = create_test_user(username='zbxhostgroup rules user') + user.is_superuser = True + user.save() + + zabbix_hostgroup = ZabbixHostgroup.objects.first() + server = zabbix_hostgroup.zabbixserver + template = ZabbixTemplate.objects.create( + name='Linux by Agent (test)', + templateid=91001, + zabbixserver=server, + ) + ZabbixTemplateRule.objects.create( + name='Linux', + pattern=r'Ubuntu|Linux', + zabbixtemplate=template, + zabbixhostgroup=zabbix_hostgroup, + enabled=True, + priority=100, + ) + + request = RequestFactory().get('/dummy') + request.user = user + context = ZabbixHostgroupView().get_extra_context(request, zabbix_hostgroup) + + self.assertIsNone(context.get('hostgroupassignment_table')) + rule_table = context.get('templaterule_table') + self.assertIsNotNone(rule_table) + self.assertIsInstance(rule_table, ZabbixTemplateRuleHostgroupViewTable) + self.assertGreater(len(list(rule_table.rows)), 0) diff --git a/nbxsync/tests/views/test_zabbixhostinventory.py b/nbxsync/tests/views/test_zabbixhostinventory.py index 9cadf77e..3ac6eda7 100644 --- a/nbxsync/tests/views/test_zabbixhostinventory.py +++ b/nbxsync/tests/views/test_zabbixhostinventory.py @@ -240,6 +240,21 @@ def test_clean_raises_if_multiple_assignments(self): self.assertIn('virtualmachine', form.errors) self.assertIn('only be assigned to one object', form.errors['virtualmachine'][0]) + def test_clean_accepts_inventory_tag_string_with_device_assignment(self): + """extras.Tag is mapped to 'tag', but inventory.tag is a Zabbix string.""" + form = ZabbixHostInventoryForm( + data={ + 'inventory_mode': 0, + 'device': self.devices[1].pk, + 'tag': 'TAG', + 'alias': 'Alias', + } + ) + + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.assigned_object, self.devices[1]) + self.assertEqual(form.cleaned_data['tag'], 'TAG') + def test_clean_sets_assigned_object_none_if_unassigned(self): form = ZabbixHostInventoryForm(data={'alias': 'Unassigned Inventory'}) diff --git a/nbxsync/tests/views/test_zabbixtemplaterule.py b/nbxsync/tests/views/test_zabbixtemplaterule.py new file mode 100644 index 00000000..3658b61d --- /dev/null +++ b/nbxsync/tests/views/test_zabbixtemplaterule.py @@ -0,0 +1,60 @@ +from django.urls import reverse + +from utilities.testing import ViewTestCases + +from nbxsync.models import ZabbixServer, ZabbixTemplate, ZabbixTemplateRule + + +class ZabbixTemplateRuleTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = ZabbixTemplateRule + + def _get_base_url(self): + return 'plugins:nbxsync:zabbixtemplaterule_{}' + + @classmethod + def setUpTestData(cls): + server = ZabbixServer.objects.create(name='Rule View Server', url='http://zabbix.local', token='abc123', validate_certs=True) + cls.templates = [ + ZabbixTemplate.objects.create(name='Windows by Zabbix agent', zabbixserver=server, templateid=10081), + ZabbixTemplate.objects.create(name='Linux by Zabbix agent', zabbixserver=server, templateid=10001), + ] + + ZabbixTemplateRule.objects.bulk_create( + [ + ZabbixTemplateRule(name='Windows', pattern='Windows', zabbixtemplate=cls.templates[0]), + ZabbixTemplateRule(name='Linux', pattern='Ubuntu|Debian', zabbixtemplate=cls.templates[1]), + ZabbixTemplateRule(name='Disabled', pattern='Nothing', zabbixtemplate=cls.templates[1], enabled=False), + ] + ) + + cls.form_data = { + 'name': 'FormRule', + 'description': 'Rule created through the form', + 'pattern': 'Windows Server 20[0-9]{2}', + 'zabbixtemplate': cls.templates[0].pk, + 'enabled': True, + 'priority': 50, + } + + cls.bulk_edit_data = { + 'priority': 500, + 'enabled': False, + } + + def test_invalid_pattern_is_rejected_by_the_form(self): + self.add_permissions('nbxsync.add_zabbixtemplaterule') + data = dict(self.form_data, name='BrokenRule', pattern='Windows (') + + response = self.client.post(reverse('plugins:nbxsync:zabbixtemplaterule_add'), data) + + self.assertHttpStatus(response, 200) + self.assertFalse(ZabbixTemplateRule.objects.filter(name='BrokenRule').exists()) diff --git a/nbxsync/urls.py b/nbxsync/urls.py index c8674e6b..6000c268 100644 --- a/nbxsync/urls.py +++ b/nbxsync/urls.py @@ -197,6 +197,15 @@ path('zabbixconfigurationgroupassignment//edit/', ZabbixConfigurationGroupAssignmentEditView.as_view(), name='zabbixconfigurationgroupassignment_edit'), path('zabbixconfigurationgroupassignment//delete/', ZabbixConfigurationGroupAssignmentDeleteView.as_view(), name='zabbixconfigurationgroupassignment_delete'), path('zabbixconfigurationgroupassignment//changelog/', ObjectChangeLogView.as_view(), name='zabbixconfigurationgroupassignment_changelog', kwargs={'model': ZabbixConfigurationGroupAssignment}), + # Zabbix Template Rule + path('zabbixtemplaterule/', ZabbixTemplateRuleListView.as_view(), name='zabbixtemplaterule_list'), + path('zabbixtemplaterule/add/', ZabbixTemplateRuleEditView.as_view(), name='zabbixtemplaterule_add'), + path('zabbixtemplaterule/edit/', ZabbixTemplateRuleBulkEditView.as_view(), name='zabbixtemplaterule_bulk_edit'), + path('zabbixtemplaterule/delete/', ZabbixTemplateRuleBulkDeleteView.as_view(), name='zabbixtemplaterule_bulk_delete'), + path('zabbixtemplaterule//', ZabbixTemplateRuleView.as_view(), name='zabbixtemplaterule'), + path('zabbixtemplaterule//edit/', ZabbixTemplateRuleEditView.as_view(), name='zabbixtemplaterule_edit'), + path('zabbixtemplaterule//delete/', ZabbixTemplateRuleDeleteView.as_view(), name='zabbixtemplaterule_delete'), + path('zabbixtemplaterule//changelog/', ObjectChangeLogView.as_view(), name='zabbixtemplaterule_changelog', kwargs={'model': ZabbixTemplateRule}), # Sync Device/VM Object path('zabbixhost///sync', TriggerHostSyncJobView.as_view(), name='zabbixhost_sync'), path('zabbixhost///sync-info/', ZabbixSyncInfoModalView.as_view(), name='zabbixhost_info'), diff --git a/nbxsync/utils/host_binding.py b/nbxsync/utils/host_binding.py new file mode 100644 index 00000000..b715818b --- /dev/null +++ b/nbxsync/utils/host_binding.py @@ -0,0 +1,255 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError, transaction + +from nbxsync.models import ZabbixHostBinding, ZabbixServerAssignment +from nbxsync.settings import get_plugin_settings + +logger = logging.getLogger(__name__) + +__all__ = ( + 'ManagedHost', + 'get_host_binding', + 'set_host_binding', + 'delete_host_binding', + 'delete_host_binding_by_id', + 'iter_host_bindings', + 'get_managed_host_id', + 'iter_managed_hosts', + 'iter_managed_servers', + 'backfill_or_resolve_conflict', + 'HostBindingDeleteProxy', +) + + +class ManagedHost: + """Assignment-like identity for a managed (object, zabbixserver) pair. + + Readers that previously used ``ZabbixServerAssignment.hostid`` should take + ``hostid`` from here: after the first sync that field is cleared and the + durable id lives on ``ZabbixHostBinding``. + """ + + def __init__(self, zabbixserver, hostid, assignment=None): + self.zabbixserver = zabbixserver + self.zabbixserver_id = getattr(zabbixserver, 'pk', None) + self.hostid = hostid + self.assignment = assignment + + +def get_host_binding(instance, zabbixserver): + """Return the ZabbixHostBinding for a given NetBox object and server, if any.""" + ct = ContentType.objects.get_for_model(instance) + return ZabbixHostBinding.objects.filter( + zabbixserver=zabbixserver, + assigned_object_type=ct, + assigned_object_id=instance.pk, + ).first() + + +def set_host_binding(instance, zabbixserver, hostid, hostname=''): + """Create or update the binding for ``instance`` -> ``hostid``. + + Raises ``RuntimeError`` if the hostid is already bound to another object + on the same server (duplicate managed identity). + """ + ct = ContentType.objects.get_for_model(instance) + try: + # Own savepoint so a uniqueness conflict does not abort an outer atomic block. + with transaction.atomic(): + binding, _ = ZabbixHostBinding.objects.update_or_create( + zabbixserver=zabbixserver, + assigned_object_type=ct, + assigned_object_id=instance.pk, + defaults={'hostid': hostid, 'hostname': hostname}, + ) + except IntegrityError as exc: + raise RuntimeError(f'Host binding conflict for {instance} on {zabbixserver}: hostid {hostid} or object is already bound to another host') from exc + return binding + + +def delete_host_binding(instance, zabbixserver=None): + """Remove all bindings for ``instance``, optionally scoped to one server.""" + ct = ContentType.objects.get_for_model(instance) + qs = ZabbixHostBinding.objects.filter(assigned_object_type=ct, assigned_object_id=instance.pk) + if zabbixserver is not None: + qs = qs.filter(zabbixserver=zabbixserver) + qs.delete() + + +def delete_host_binding_by_id(binding_id): + """Delete one binding after its remote host has been retired successfully.""" + ZabbixHostBinding.objects.filter(pk=binding_id).delete() + + +def iter_host_bindings(instance): + """Iterate over all bindings for ``instance``.""" + ct = ContentType.objects.get_for_model(instance) + return ZabbixHostBinding.objects.filter( + assigned_object_type=ct, + assigned_object_id=instance.pk, + ).select_related('zabbixserver') + + +def get_managed_host_id(instance, zabbixserver): + """Return the Zabbix hostid for ``instance`` on ``zabbixserver``. + + Prefer the durable ``ZabbixHostBinding``. Fall back to a *direct* + ``ZabbixServerAssignment.hostid`` so leftover pre-binding rows still + resolve. Inherited Site/Role assignment rows are never used as identity: + those hostids would belong to the parent object, not this host. + """ + if instance is None or zabbixserver is None or getattr(instance, 'pk', None) is None: + return None + + binding = get_host_binding(instance, zabbixserver) + if binding and binding.hostid: + return binding.hostid + + ct = ContentType.objects.get_for_model(instance) + assignment = ( + ZabbixServerAssignment.objects.filter( + assigned_object_type=ct, + assigned_object_id=instance.pk, + zabbixserver=zabbixserver, + ) + .only('hostid') + .first() + ) + if assignment and assignment.hostid: + return assignment.hostid + return None + + +def iter_managed_hosts(instance, *, require_hostid=False): + """Yield ``ManagedHost`` for every Zabbix server this instance is managed on. + + Union of durable bindings and resolved server assignments (including + inherited Site/Role rows). Servers or assignments with ``sync_enabled=False`` + are skipped. When ``require_hostid`` is set, inherited assignments without a + binding are omitted — they have no hostid to query. + """ + if instance is None or getattr(instance, 'pk', None) is None: + return + + seen = set() + + for binding in iter_host_bindings(instance): + server = binding.zabbixserver + if not server.sync_enabled: + continue + seen.add(server.pk) + if require_hostid and not binding.hostid: + continue + yield ManagedHost(server, binding.hostid) + + ct = ContentType.objects.get_for_model(instance) + direct_assignments = ZabbixServerAssignment.objects.filter( + assigned_object_type=ct, + assigned_object_id=instance.pk, + sync_enabled=True, + zabbixserver__sync_enabled=True, + ).select_related('zabbixserver') + for assignment in direct_assignments: + if assignment.zabbixserver_id in seen: + continue + seen.add(assignment.zabbixserver_id) + hostid = assignment.hostid + if require_hostid and not hostid: + continue + yield ManagedHost(assignment.zabbixserver, hostid, assignment) + + if require_hostid: + return + + from nbxsync.utils.inheritance import get_assigned_zabbixobjects + + for assignment in get_assigned_zabbixobjects(instance).get('server_assignments', []) or []: + if assignment.zabbixserver_id in seen: + continue + if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: + continue + seen.add(assignment.zabbixserver_id) + yield ManagedHost(assignment.zabbixserver, get_managed_host_id(instance, assignment.zabbixserver), assignment) + + +def iter_managed_servers(instance): + """Yield distinct enabled ``ZabbixServer`` objects this instance is managed on.""" + for managed in iter_managed_hosts(instance, require_hostid=False): + yield managed.zabbixserver + + +def _host_tags_to_dict(host): + return {tag['tag']: tag['value'] for tag in host.get('tags', [])} + + +def _expected_source_tags(instance): + pluginsettings = get_plugin_settings() + return { + pluginsettings.objtag_type: str(type(instance).__name__).lower(), + pluginsettings.objtag_id: str(instance.pk), + } + + +def backfill_or_resolve_conflict(instance, zabbixserver, api, hostname=None): + """Adopt an existing Zabbix host by its managed source tags. + + If no host with the same technical name exists, returns ``None`` so the + caller can create a new one. If an unmanaged host with the same name + exists, or if the matching host belongs to another NetBox object, a + ``RuntimeError`` is raised. + + ``hostname`` must be the technical host name actually sent to Zabbix + (custom-field hostname when set, then sanitized) — the same value + ``HostSync.get_name_value()`` / ``sanitize_string()`` produce. Falling back + to the raw NetBox name would miss renamed or custom-hostname hosts. + + Adoption is gated by the ``adopt_existing_hosts`` setting: taking over a + host makes NetBox authoritative over its configuration, so operators opt in + explicitly instead of discovering it after the first sync. + """ + if hostname is None: + name = str(instance.name) if hasattr(instance, 'name') else str(instance) + else: + name = str(hostname) + hosts = api.host.get(filter={'host': name}, selectTags='extend') + if isinstance(hosts, dict): + hosts = hosts.get('result', []) + + if not hosts: + return None + + expected = _expected_source_tags(instance) + matches = [host for host in hosts if all(_host_tags_to_dict(host).get(k) == v for k, v in expected.items())] + + if len(matches) == 1: + if not get_plugin_settings().adopt_existing_hosts: + raise RuntimeError(f'Zabbix host "{name}" (hostid {matches[0]["hostid"]}) already carries the managed identity for {instance} but is not bound in NetBox. Set nbxsync adopt_existing_hosts = True to let nbxsync take ownership of it, or remove the host from Zabbix first.') + logger.info('Adopting existing Zabbix host %s (hostid %s) for %s on %s', name, matches[0]['hostid'], instance, zabbixserver) + return int(matches[0]['hostid']) + + if len(hosts) == 1 and not matches: + raise RuntimeError(f'Unmanaged host conflict: a Zabbix host named "{name}" exists but does not match the managed identity for {instance}.') + + raise RuntimeError(f'Ambiguous host conflict: {len(hosts)} Zabbix hosts found with name "{name}".') + + +class HostBindingDeleteProxy: + """Assignment-like delete target backed by a durable host binding.""" + + _is_inherited_copy = False + + def __init__(self, binding, assigned_object=None): + self.binding_id = binding.pk + self.zabbixserver = binding.zabbixserver + self.hostid = binding.hostid + self.assigned_object = assigned_object if assigned_object is not None else binding.assigned_object + self.assigned_object_type = binding.assigned_object_type + self.assigned_object_id = binding.assigned_object_id + + def update_sync_info(self, *args, **kwargs): + pass + + def save(self, *args, **kwargs): + pass diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index d8e9e27e..5a1fa419 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, ZabbixTemplateRule 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,472 @@ 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). + # TemplateRule matching runs after this so explicit assignments always win. + merged_templates = _merge_direct_and_inherited(direct_templates, inherited['templates'], 'zabbixtemplate_id') + resolved_template_ids = {getattr(obj, 'zabbixtemplate_id') for obj in merged_templates} + merged_hostgroups = _merge_direct_and_inherited(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id') + resolved_hostgroup_ids = {getattr(obj, 'zabbixhostgroup_id') for obj in merged_hostgroups} + merged_tags = _merge_direct_and_inherited(direct_tags, inherited['tags'], 'id') + resolved_tag_ids = {obj.zabbixtag_id for obj in merged_tags} + + # ConfigGroup members are also expanded at resolve time so host sync does + # not depend on the async RQ propagate job having already cloned rows onto + # the device/site. Durable propagation remains for UI; this path is the + # sync-time source of truth. + if configurationgroup: + cg = configurationgroup.zabbixconfigurationgroup + cg_ct = ContentType.objects.get_for_model(cg) + cg_templates = ZabbixTemplateAssignment.objects.filter( + assigned_object_type=cg_ct, + assigned_object_id=cg.id, + ).select_related('zabbixtemplate') + if zabbixserver is not None: + cg_templates = cg_templates.filter(zabbixtemplate__zabbixserver=zabbixserver) + for ta in cg_templates: + if ta.zabbixtemplate_id in resolved_template_ids: + continue + wrapper = _copy.copy(ta) + wrapper.pk = None + wrapper._is_inherited_copy = True + wrapper.assigned_object_type = content_type + wrapper.assigned_object_id = instance.id + merged_templates.append(wrapper) + resolved_template_ids.add(ta.zabbixtemplate_id) + + platform = getattr(instance, 'platform', None) + role = getattr(instance, 'role', None) + device_type = getattr(instance, 'device_type', None) + manufacturer_id = getattr(device_type, 'manufacturer_id', None) if device_type is not None else None + try: + object_tag_slugs = {tag.slug for tag in instance.tags.all()} if hasattr(instance, 'tags') else set() + except Exception: + object_tag_slugs = set() + rules_qs = ZabbixTemplateRule.objects.filter(enabled=True).select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + if zabbixserver: + rules_qs = rules_qs.filter(zabbixtemplate__zabbixserver=zabbixserver) + for rule in rules_qs.order_by('priority', 'name'): + if not rule.matches( + platform.name if platform else None, + role_name=role.name if role else None, + netbox_tags=object_tag_slugs, + manufacturer_id=manufacturer_id, + ): + continue + inherited_from = f'Regex: {rule.name}' + if rule.zabbixtemplate_id and rule.zabbixtemplate_id not in resolved_template_ids: + wrapper = ZabbixTemplateAssignment( + zabbixtemplate=rule.zabbixtemplate, + assigned_object_type=content_type, + assigned_object_id=instance.id, + ) + wrapper.pk = None + wrapper._is_inherited_copy = True + wrapper._inherited_from = inherited_from + merged_templates.append(wrapper) + resolved_template_ids.add(rule.zabbixtemplate_id) + if rule.zabbixhostgroup_id and rule.zabbixhostgroup_id not in resolved_hostgroup_ids: + wrapper = ZabbixHostgroupAssignment( + zabbixhostgroup=rule.zabbixhostgroup, + assigned_object_type=content_type, + assigned_object_id=instance.id, + ) + wrapper.pk = None + wrapper._is_inherited_copy = True + wrapper._inherited_from = inherited_from + merged_hostgroups.append(wrapper) + resolved_hostgroup_ids.add(rule.zabbixhostgroup_id) + if rule.zabbixtag_id and rule.zabbixtag_id not in resolved_tag_ids: + wrapper = ZabbixTagAssignment( + zabbixtag=rule.zabbixtag, + assigned_object_type=content_type, + assigned_object_id=instance.id, + ) + wrapper.pk = None + wrapper._is_inherited_copy = True + wrapper._inherited_from = inherited_from + merged_tags.append(wrapper) + resolved_tag_ids.add(rule.zabbixtag_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/hostgroupsync.py b/nbxsync/utils/sync/hostgroupsync.py index 1a548378..697488a4 100644 --- a/nbxsync/utils/sync/hostgroupsync.py +++ b/nbxsync/utils/sync/hostgroupsync.py @@ -1,6 +1,33 @@ +import logging + from .syncbase import ZabbixSyncBase from nbxsync.models import ZabbixHostgroup +logger = logging.getLogger(__name__) + + +def ensure_parent_hostgroups(api, name): + """Materialize missing parent groups of a nested name, parent-first. + + Zabbix treats nesting as a name convention: creating 'A/B/C' does not + auto-create 'A' and 'A/B' — they stay phantom groups that can never + hold hosts or permissions. Because Zabbix only inherits user-group + permissions and tag filters into a subgroup when its parent already + exists, parents must be created before their children. + + Idempotent: existing parents are found by exact name and left alone. + Malformed names (empty segments, leading/trailing slashes) are skipped; + the Zabbix API rejects them on the leaf create either way.""" + if '//' in name or name != name.strip('/'): + return + segments = name.split('/') + for depth in range(1, len(segments)): + parent = '/'.join(segments[:depth]) + if api.hostgroup.get(filter={'name': parent}): + continue + result = api.hostgroup.create({'name': parent}) + logger.info("Created parent hostgroup '%s' (groupid=%s)", parent, result['groupids'][0]) + class HostGroupSync(ZabbixSyncBase): id_field = 'zabbixhostgroup.groupid' @@ -9,13 +36,34 @@ class HostGroupSync(ZabbixSyncBase): def get_name_value(self): name, _state = self.obj.render() + if not _state and self.obj.is_template(): + return None return name + def try_create(self): + # For template-based assignments that can't render against the + # assigned object (e.g. {{ object.role.name }} on a DeviceRole), + # skip creation — the group is created on-demand during host sync. + name, state = self.obj.render() + if not state and self.obj.is_template(): + return None + self._ensure_parent_groups(name) + return super().try_create() + + def _ensure_parent_groups(self, name): + """Create missing parents for nested group names before the leaf. + + Zabbix inherits user-group permissions and tag filters into a subgroup + only when its parent already exists — see ensure_parent_hostgroups().""" + ensure_parent_hostgroups(self.api, name) + def api_object(self): return self.api.hostgroup def get_create_params(self): name, _state = self.obj.render() + if not _state and self.obj.is_template(): + return {} return { 'name': name, } @@ -64,6 +112,8 @@ def set_id(self, value): return name, _state = self.obj.render() + if not _state and self.obj.is_template(): + return # Cannot render template against assignment object zabbixserver = self.obj.zabbixhostgroup.zabbixserver # Try to find an existing local representation for the rendered Zabbix group. diff --git a/nbxsync/utils/sync/hostinterfacesync.py b/nbxsync/utils/sync/hostinterfacesync.py index 93a8cf86..f8b25f26 100644 --- a/nbxsync/utils/sync/hostinterfacesync.py +++ b/nbxsync/utils/sync/hostinterfacesync.py @@ -1,5 +1,6 @@ from ipam.models import IPAddress -from nbxsync.models import ZabbixServerAssignment + +from nbxsync.utils.host_binding import get_managed_host_id from .syncbase import ZabbixSyncBase @@ -14,23 +15,47 @@ def api_object(self): def get_name_value(self): return self.obj.assigned_object.name - def get_create_params(self): + def _resolve_hostid(self): + """Hostid from sync context, else the durable binding / leftover direct assignment.""" hostid = self.context.get('hostid', None) - zbxserverassignment = None + if hostid: + return hostid + instance = self.context.get('_instance') or getattr(self.obj, 'assigned_object', None) + return get_managed_host_id(instance, getattr(self.obj, 'zabbixserver', None)) + def find_by_name(self): + hostid = self._resolve_hostid() if not hostid: - # No HostID, get it from the assignment - zbxserverassignment = ZabbixServerAssignment.objects.filter(assigned_object_type=self.obj.assigned_object_type, assigned_object_id=self.obj.assigned_object.id).first() - # If the assignment isnt found... Return - if not zbxserverassignment: - return {} + return [] + # output='extend' is required so type/main/port are present. + # IntegerChoices.__str__ is the label ('SNMP'), not the Zabbix type id. + candidates = self.api_object().get(hostids=[int(hostid)], output='extend', filter={'type': str(int(self.obj.type))}) or [] + own = [] + for iface in candidates: + if 'hostid' in iface: + try: + if int(iface['hostid']) != int(hostid): + continue + except (TypeError, ValueError): + continue + own.append(iface) + return own - # Update the hostid field :) - hostid = zbxserverassignment.hostid + def get_create_params(self): + hostid = self._resolve_hostid() + if not hostid: + return {} 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..005a9e51 100644 --- a/nbxsync/utils/sync/hostsync.py +++ b/nbxsync/utils/sync/hostsync.py @@ -1,17 +1,22 @@ +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.host_binding import backfill_or_resolve_conflict, delete_host_binding, delete_host_binding_by_id, get_host_binding, set_host_binding +from nbxsync.utils.sync.hostgroupsync import ensure_parent_hostgroups from nbxsync.utils.sync.hostinterfacesync import HostInterfaceSync +logger = logging.getLogger(__name__) + class HostSync(ZabbixSyncBase): id_field = 'hostid' @@ -20,27 +25,119 @@ 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 _resolve_binding(self): + """Resolve a durable hostid for the sync target. + + Order: + 1. Existing ``ZabbixHostBinding`` for the (server, instance) pair. + 2. Legacy ``ZabbixServerAssignment.hostid`` on a direct assignment. + 3. Backfill an existing Zabbix host that carries the managed + ``objtag_type``/``objtag_id`` identity tags. + + The resolved hostid is written to ``self.obj.hostid`` so the rest of + the sync engine can continue to use the existing id-based paths. + """ + sync_target = self._get_sync_target() + zabbixserver = self.obj.zabbixserver + + binding = get_host_binding(sync_target, zabbixserver) + if binding: + self.obj.hostid = binding.hostid + return + + if self.obj.hostid: + self._migrate_legacy_hostid(sync_target, zabbixserver) + return + + # No binding and no legacy id: try to adopt an existing managed host. + try: + technical_name = self.sanitize_string(input_str=str(self.get_name_value())) + existing_hostid = backfill_or_resolve_conflict(sync_target, zabbixserver, self.api, hostname=technical_name) + except RuntimeError: + raise + if existing_hostid: + self.obj.hostid = existing_hostid + set_host_binding(sync_target, zabbixserver, existing_hostid, hostname=technical_name) + + def _migrate_legacy_hostid(self, sync_target, zabbixserver): + """Move a direct-assignment hostid into a durable binding.""" + hostid = int(self.obj.hostid) + binding = set_host_binding(sync_target, zabbixserver, hostid, hostname=str(self.get_name_value())) + self.obj.hostid = binding.hostid + + def _persist_binding(self): + """Store/update the binding after a successful create or update.""" + sync_target = self._get_sync_target() + hostid = self.get_id() + if not hostid: + return + set_host_binding(sync_target, self.obj.zabbixserver, int(hostid), hostname=str(self.get_name_value())) + + def _clear_direct_hostid(self): + """After migrating to bindings, clear the legacy hostid from direct assignments.""" + if not self._should_persist(): + return + if not self.obj.pk: + return + if self.obj.hostid: + self.obj.hostid = None + self.obj.save(update_fields=['hostid']) + + def _zabbix_host_missing(self): + """Return True if the current hostid has no matching remote host.""" + hostid = self.get_id() + if not hostid: + return True + return not self.find_by_id() + + def set_id(self, value): + super().set_id(value) + self._persist_binding() + + def sync_to_zabbix(self, object_id): + super().sync_to_zabbix(object_id) + self._persist_binding() + + def sync(self): + self._resolve_binding() + super().sync() + self._clear_direct_hostid() + 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 +146,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 +163,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 +214,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 +246,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 +317,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 +337,9 @@ def get_hostinterface_attributes(self): return result def get_hostinterface_types(self): - hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) + # use_oob_ip interfaces without a resolvable OOB IP are filtered out once + # in SyncHostJob.sync_host(), so every interface here is expected to sync. + hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) or [] return list({interface.type for interface in hostinterfaces}) def get_templates_clear_attributes(self): @@ -251,7 +351,7 @@ def get_templates_clear_attributes(self): currently_assigned_templates = self.api.template.get(hostids=int(self.obj.hostid)) # Flatten current templates to a set of integers - current_ids = set(int(current_template['templateid']) for current_template in currently_assigned_templates) + current_ids = {int(current_template['templateid']) for current_template in currently_assigned_templates} # Extract actual template list from the dict to_be_templates = self.templates.get('templates', []) @@ -282,7 +382,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,27 +408,49 @@ 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 = getattr(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} def get_groups(self): groups = [] + errors = [] for group in self.obj.assigned_objects.get('hostgroups', []): # 1) If we already know the Zabbix groupid, use it (fast path). gid = getattr(getattr(group, 'zabbixhostgroup', None), 'groupid', None) @@ -337,34 +459,49 @@ def get_groups(self): continue # 2) Otherwise, try to resolve by name (e.g., for template-like objects). - name, _status = ('', False) + name, status = ('', False) try: - name, _status = group.render() - except Exception: - _status = False + name, status = group.render(object=self._get_sync_target()) + except Exception as exc: + errors.append(f'Failed to render hostgroup for {self._get_sync_target()}: {exc}') + continue + if not (status and name): + errors.append(f'Hostgroup for {self._get_sync_target()} rendered empty; refusing to omit it silently') + continue - if _status and name: - zbx_result = self.api.hostgroup.get(search={'name': name}) or [] - if len(zbx_result) == 1 and 'groupid' in zbx_result[0]: + try: + zbx_result = self.api.hostgroup.get(filter={'name': name}) or [] + if zbx_result: groups.append({'groupid': zbx_result[0]['groupid']}) - elif zbx_result: - # If multiple, prefer exact-name match if available - match = next((g for g in zbx_result if g.get('name') == name and 'groupid' in g), None) - if match: - groups.append({'groupid': match['groupid']}) - # If no gid and no resolvable name, skip silently + continue + # Materialize nested parents before the leaf so the subgroup + # inherits the parent's user-group permissions (see + # ensure_parent_hostgroups). + ensure_parent_hostgroups(self.api, name) + created = self.api.hostgroup.create({'name': name}) + gid = created.get('groupids', [None])[0] + if gid: + groups.append({'groupid': gid}) + else: + errors.append(f'Zabbix did not return a groupid when creating hostgroup "{name}"') + except Exception as exc: + errors.append(f'Failed to resolve/create hostgroup "{name}": {exc}') + if errors: + raise RuntimeError('; '.join(errors)) return groups 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 +512,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 +539,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() @@ -420,158 +558,331 @@ def verify_maintenancewindow(self): ) ) - def delete(self): - if not self.obj.hostid: + def _clear_deleted_host_state(self, sync_target, zabbixserver): + binding_id = getattr(self.obj, 'binding_id', None) + if binding_id is not None: + delete_host_binding_by_id(binding_id) + elif sync_target is not None: + delete_host_binding(sync_target, zabbixserver) + + if binding_id is None and self._should_persist(): try: - self.obj.update_sync_info(success=False, message='Host already deleted or missing host ID.') + self.obj.hostid = None + self.obj.save() + except (ValidationError, AttributeError): + pass + + if sync_target is not None: + try: + object_ct = ContentType.objects.get_for_model(sync_target) + ZabbixHostInterface.objects.filter( + assigned_object_type=object_ct, + assigned_object_id=sync_target.pk, + zabbixserver=zabbixserver, + ).update(interfaceid=None) except Exception: pass - return - # The assigned object (Device/VM) may already be gone if this job runs - # after a cascade delete. Resolve it once and guard all uses below. - assigned_object = self.obj.assigned_object - if assigned_object is None: - # NetBox object is gone — still delete the Zabbix host, then clean up - # what we can without touching the now-invalid assignment row. + def delete(self): # noqa: C901 + """Delete a host by durable ID and remove its binding only after success.""" + sync_target = self._get_sync_target() + zabbixserver = self.obj.zabbixserver + + binding = get_host_binding(sync_target, zabbixserver) if sync_target is not None else None + hostid = binding.hostid if binding else self.obj.hostid + if not hostid: try: - self.api_object().delete([self.obj.hostid]) - except Exception as e: - raise RuntimeError(f'Failed to delete orphaned host {self.obj.hostid} from Zabbix: {e}') + self.obj.update_sync_info(success=False, message='Host already deleted or missing host ID.') + except Exception: + pass return try: - object_ct = ContentType.objects.get_for_model(assigned_object) - maintenances = self.api.maintenance.get(hostids=[self.obj.hostid], selectHosts='extend') - for mw in maintenances: - # Check per maintenance window if this host is the only host in the window or not. If it is, we can delete it - # If not, we should delete the host from the Netbox window - if len(mw['hosts']) > 1: - # Filter out the hostid - hosts = [{'hostid': host['hostid']} for host in mw['hosts'] if int(host['hostid']) != self.obj.hostid] - # Update the maintenance window in Zabbix without our hostid in it - self.api.maintenance.update(maintenanceid=mw['maintenanceid'], hosts=hosts) - for assignment in ZabbixMaintenanceObjectAssignment.objects.filter(maintenanceid=mw['maintenanceid'], assigned_object_type=object_ct, assigned_object_id=assigned_object.id): - assignment.delete() # Delete the Assignment from Netbox; - - # If our host is the only one in the Maintenance Object - # Delete it... - else: - self.api.maintenance.delete([mw['maintenanceid']]) - ZabbixMaintenance.objects.get(maintenanceid=mw['maintenanceid']).delete() - - # Delete from Zabbix - self.api_object().delete([self.obj.hostid]) + api_object = self.api_object() + get_remote_hosts = getattr(api_object, 'get', None) + remote_hosts = get_remote_hosts(hostids=[hostid]) if callable(get_remote_hosts) else [{'hostid': hostid}] + if isinstance(remote_hosts, dict): + remote_hosts = remote_hosts.get('result', []) + + if not remote_hosts: + self._clear_deleted_host_state(sync_target, zabbixserver) + try: + self.obj.update_sync_info(success=True, message='Host was already absent from Zabbix.') + except Exception: + pass + return + + if sync_target is not None: + object_ct = ContentType.objects.get_for_model(sync_target) + maintenances = self.api.maintenance.get(hostids=[hostid], selectHosts='extend') + for maintenance in maintenances: + if len(maintenance['hosts']) > 1: + hosts = [{'hostid': host['hostid']} for host in maintenance['hosts'] if int(host['hostid']) != int(hostid)] + self.api.maintenance.update(maintenanceid=maintenance['maintenanceid'], hosts=hosts) + ZabbixMaintenanceObjectAssignment.objects.filter( + maintenanceid=maintenance['maintenanceid'], + assigned_object_type=object_ct, + assigned_object_id=sync_target.pk, + ).delete() + else: + self.api.maintenance.delete([maintenance['maintenanceid']]) + ZabbixMaintenance.objects.get(maintenanceid=maintenance['maintenanceid']).delete() + + api_object.delete([hostid]) + self._clear_deleted_host_state(sync_target, zabbixserver) try: - # Unset the host ID and save - self.obj.hostid = None - self.obj.save() - except ValidationError: + self.obj.update_sync_info(success=True, message='Host deleted from Zabbix.') + except Exception: pass - - # Also clear host IDs from related interfaces + except Exception as exc: try: - ZabbixHostInterface.objects.filter(assigned_object_type=self.obj.assigned_object_type, assigned_object_id=assigned_object.id, zabbixserver=self.obj.zabbixserver).update(interfaceid=None) - - self.obj.update_sync_info(success=True, message='Host deleted from Zabbix.') + self.obj.update_sync_info(success=False, message=f'Failed to delete host: {exc}') except Exception: pass + raise RuntimeError(f'Failed to delete host {hostid} from Zabbix: {exc}') from exc - except Exception as e: - self.obj.update_sync_info(success=False, message=f'Failed to delete host: {e}') - raise RuntimeError(f'Failed to delete host {self.obj.hostid} from Zabbix: {e}') + def _ensure_hostid(self): + """Populate ``self.obj.hostid`` from a durable binding when the assignment row was cleared. - def check_default_hostinterface(self): - if not self.obj.hostid: + ``check_default_hostinterface`` and ``verify_hostinterfaces`` are invoked + as standalone operations (not via ``sync()``), so they never run + ``_resolve_binding()``. After ``_clear_direct_hostid`` the assignment + hostid is None even though the binding still holds the real id. + """ + if self.obj.hostid: return + sync_target = self._get_sync_target() + binding = get_host_binding(sync_target, self.obj.zabbixserver) + if binding and binding.hostid: + self.obj.hostid = binding.hostid + + def _remote_hostinterfaces_by_type(self, hostid): + """Return this host's remote interfaces grouped by Zabbix type. + + ``hostinterface.get`` must be host-scoped and use ``output='extend'``. + Without ``extend``, ``type``/``main``/``port`` can come back empty; a + loosely scoped get can also include another host's default. Either + mistake makes this host look like it already has an SNMP default, so + the flip path creates the first local SNMP interface as ``main=0`` + and Zabbix rejects it with "No default interface for SNMP type". + """ + raw = self.api.hostinterface.get(hostids=[int(hostid)], output='extend') or [] + by_type = {} + defaults = {} + for iface in raw: + if 'hostid' in iface: + try: + if int(iface['hostid']) != int(hostid): + continue + except (TypeError, ValueError): + continue + try: + itype = int(iface.get('type')) + iid = str(int(iface.get('interfaceid'))) + except (TypeError, ValueError): + continue + by_type.setdefault(itype, []).append(iface) + if int(iface.get('main', 0)) == 1: + defaults[itype] = iid + return by_type, defaults - hostid = str(int(self.obj.hostid)) - netbox_hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) - zabbix_hostinterfaces = self.api.hostinterface.get(hostids=hostid) - - netbox_default_obj_by_type = {} - netbox_default_id_by_type = {} - zabbix_default_id_by_type = {} + def _match_remote_interface(self, nb_obj, remote_of_type): + """Pick an existing remote interface of this type on the same endpoint. - # Loop through all Netbox Host Interfaces and get the default interface id per type + ``find_by_name`` also requires ``main`` to match. Default reconciliation + must be able to promote a same-port non-default (e.g. leftover SNMP + ``main=0``) instead of creating a duplicate. + """ + port = str(nb_obj.port) + useip = str(int(nb_obj.useip)) + same_endpoint = [iface for iface in remote_of_type if str(iface.get('port', '')) == port and str(iface.get('useip', '')) == useip] + if not same_endpoint: + return None + mains = [iface for iface in same_endpoint if int(iface.get('main', 0)) == 1] + pool = mains or same_endpoint + return min(pool, key=lambda iface: int(iface['interfaceid'])) + + def _bind_interfaceid(self, nb_obj, hostinterface_id): + """Remember the remote id on the working object without persisting clones.""" + nb_obj.interfaceid = int(hostinterface_id) + # Transient ConfigGroup clones are pk=None in-memory copies. + # Saving them would INSERT a new HostInterface row without + # ConfigGroup provenance. Keep the interfaceid on the + # working object only; the next sync resolves by identity. + if not getattr(nb_obj, '_is_inherited_copy', False) and nb_obj.pk: + nb_obj.save() + return str(int(hostinterface_id)) + + def _attach_or_create_default_interface(self, nb_obj, hostid, instance, remote_of_type): + """Resolve the NetBox default onto a remote interface of the same type. + + First interface of a type on this host is created with ``main=1``. + ``main=0`` is only used when this host already has that type, so a + later atomic ``host.update`` can flip the default. Agent already + having a default does not make SNMP a non-default create. + """ + syncer = HostInterfaceSync(self.api, nb_obj, hostid=hostid, _instance=instance) + matches = syncer.find_by_name() + hostinterface_id = matches[0].get('interfaceid') if matches else None + if not hostinterface_id: + matched = self._match_remote_interface(nb_obj, remote_of_type) + hostinterface_id = matched.get('interfaceid') if matched else None + if hostinterface_id: + return self._bind_interfaceid(nb_obj, hostinterface_id) + + params = syncer.get_create_params() + if not params: + return None + + # Zabbix requires one default per interface type. The first remote + # interface of a type must be that default. Forcing main=0 is only + # valid when a default of this type already exists on this host. + params['main'] = 0 if remote_of_type else 1 + created = self.api.hostinterface.create(**params) + hostinterface_id = created.get('interfaceids', [None])[0] + if not hostinterface_id: + raise RuntimeError(f'Failed to create interface for type={int(nb_obj.type)}: {created}') + return self._bind_interfaceid(nb_obj, hostinterface_id) + + def _flip_default_interfaces(self, netbox_hostinterfaces, hostid, instance): + """Atomically apply NetBox main flags; Zabbix allows one default per type.""" + desired_hostinterfaces = [] for netbox_hostinterface in netbox_hostinterfaces: - if int(getattr(netbox_hostinterface, 'interface_type', 0)) == 1: - netbox_default_obj_by_type[int(netbox_hostinterface.type)] = netbox_hostinterface - interface_id = None - if getattr(netbox_hostinterface, 'interfaceid', None): - interface_id = str(int(netbox_hostinterface.interfaceid)) + syncer = HostInterfaceSync(self.api, netbox_hostinterface, hostid=hostid, _instance=instance) + params = syncer.get_update_params() + if not params or not params.get('interfaceid'): + continue + desired_hostinterfaces.append(params) + if desired_hostinterfaces: + self.api.host.update(hostid=hostid, interfaces=desired_hostinterfaces) - netbox_default_id_by_type[int(netbox_hostinterface.type)] = interface_id + def _netbox_defaults_by_type(self, netbox_hostinterfaces): + objects_by_type = {} + ids_by_type = {} + for netbox_hostinterface in netbox_hostinterfaces: + if int(getattr(netbox_hostinterface, 'interface_type', 0)) != 1: + continue + itype = int(netbox_hostinterface.type) + objects_by_type[itype] = netbox_hostinterface + interface_id = None + if getattr(netbox_hostinterface, 'interfaceid', None): + interface_id = str(int(netbox_hostinterface.interfaceid)) + ids_by_type[itype] = interface_id + return objects_by_type, ids_by_type + + def _reconcile_default_for_type(self, nb_obj, nb_id, zbx_default_id, remote_of_type, netbox_hostinterfaces, hostid, instance): + """Create, attach, or flip the default for one Zabbix interface type.""" + # Remote default exists but NetBox no longer wants one (e.g. OOB + # SNMP removed and waiting for verify_hostinterfaces to delete it). + if not nb_obj: + return - # Loop through all Zabbix Host Interfaces and get the default interface id per type - for zabbix_hostinterface in zabbix_hostinterfaces: - if int(zabbix_hostinterface.get('main', 0)) == 1: - zabbix_default_id_by_type[int(zabbix_hostinterface.get('type'))] = str(int(zabbix_hostinterface.get('interfaceid'))) + if not remote_of_type: + # First interface of this type on this host. Create (or attach) + # as main=1. Do not enter the create-as-non-default flip path + # just because some other type already has a default, or + # because a foreign host's default leaked into the inventory. + self._attach_or_create_default_interface(nb_obj, hostid, instance, remote_of_type) + return - all_types = set(netbox_default_id_by_type) | set(zabbix_default_id_by_type) + if nb_id == zbx_default_id and nb_id: + return - for hostinterface_type in sorted(all_types): - nb_default_hostinterface_obj = netbox_default_obj_by_type.get(hostinterface_type) - nb_default_hostinterface_id = netbox_default_id_by_type.get(hostinterface_type) - zbx_default_hostinterfaceid = zabbix_default_id_by_type.get(hostinterface_type) + if not nb_id: + nb_id = self._attach_or_create_default_interface(nb_obj, hostid, instance, remote_of_type) + if not nb_id: + return - if not zbx_default_hostinterfaceid: - # No zabbix interfaces? - # Nothing to do here in that case - continue + self._flip_default_interfaces(netbox_hostinterfaces, hostid, instance) - if nb_default_hostinterface_id != zbx_default_hostinterfaceid: - # If NB default interface doesn't exist yet in Zabbix, create it as non-default first - if not nb_default_hostinterface_id: - syncer = HostInterfaceSync(self.api, nb_default_hostinterface_obj, hostid=hostid) - params = syncer.get_create_params() - if not params: - continue + def check_default_hostinterface(self): + self._ensure_hostid() + if not self.obj.hostid: + return - params['main'] = 0 # create as NON-default - created = self.api.hostinterface.create(**params) - hostinterface_id = created.get('interfaceids', [None])[0] - if not hostinterface_id: - raise RuntimeError(f'Failed to create interface for type={hostinterface_type}: {created}') - - nb_default_hostinterface_obj.interfaceid = int(hostinterface_id) - nb_default_hostinterface_obj.save() - - # update local variable so the compare is correct for the flip step - nb_default_hostinterface_id = str(int(hostinterface_id)) - - # Some very 'complicated' logic to flip the main - # As Zabbix can have only 1 default/main interface at the time, we must update all interfaces at once - # As such, we loop through all hostinterfaces, and use the HostInterfaceSync module to get the create params - # That way, we can update all interfaces at once - desired_hostinterfaces = [] - for netbox_hostinterface in netbox_hostinterfaces: - syncer = HostInterfaceSync(self.api, netbox_hostinterface, hostid=hostid) - params = syncer.get_update_params() - if not params or not params.get('interfaceid'): - continue - desired_hostinterfaces.append(params) + hostid = str(int(self.obj.hostid)) + netbox_hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) or [] + remote_by_type, zabbix_default_id_by_type = self._remote_hostinterfaces_by_type(hostid) + netbox_default_obj_by_type, netbox_default_id_by_type = self._netbox_defaults_by_type(netbox_hostinterfaces) + instance = self.context.get('all_objects', {}).get('_instance') + all_types = set(netbox_default_id_by_type) | set(zabbix_default_id_by_type) | set(remote_by_type) - self.api.host.update(hostid=hostid, interfaces=desired_hostinterfaces) - return + for hostinterface_type in sorted(all_types): + self._reconcile_default_for_type( + netbox_default_obj_by_type.get(hostinterface_type), + netbox_default_id_by_type.get(hostinterface_type), + zabbix_default_id_by_type.get(hostinterface_type), + remote_by_type.get(hostinterface_type) or [], + netbox_hostinterfaces, + hostid, + instance, + ) def verify_hostinterfaces(self): # If there is no hostid, no need to continue - so fail early + self._ensure_hostid() if not self.obj.hostid: 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 = self.context.get('all_objects', {}).get('hostinterfaces', []) or [] + # Interfaces that could not be synced this run (e.g. an OOB interface on + # a device whose oob_ip was cleared) are retained rather than deleted. + retained_hostinterfaces = self.context.get('all_objects', {}).get('retained_hostinterfaces', []) or [] + considered_hostinterfaces = list(expected_hostinterfaces) + list(retained_hostinterfaces) + + # Include persisted interfaceids from both expected and retained rows. + # A previously synced OOB interface kept in retained_hostinterfaces still + # carries its interfaceid; omitting it here would delete the remote IF. + expected_ids = {int(hi.interfaceid) for hi in considered_hostinterfaces if hi.interfaceid} + + # Get currently assigned hostinterface from Zabbix. + # output must be the string 'extend' — a one-element list ['extend'] is + # treated as a field name, so type/main/port come back empty and every + # transient ConfigGroup/hierarchy interface fails identity matching and + # is deleted on the same sync that created it. + current_hostinterfaces = self.api.hostinterface.get(output='extend', hostids=self.obj.hostid) + + # Interfaces inherited from a ConfigGroup are transient copies without a + # persisted interfaceid, so they must be recognised by what Zabbix stores + # instead. Deleting them here would remove an interface that the very + # next sync recreates, and fail outright once items are linked to it. + # Match the ConfigGroup identity helper: type + main role + connect mode + # + port + dns. IP is omitted here because OOB interfaces resolve it at + # sync time and would otherwise look "stale" every run. + expected_identities = { + ( + int(hi.type), + int(hi.interface_type), + int(hi.useip), + str(hi.port), + str(hi.dns or ''), + ) + for hi in considered_hostinterfaces + if not hi.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} + # Inherited server assignments must not persist ORM rows, but remote + # stale-interface cleanup is still required for Site-level proxies. + # Gate that destructive remote work on allow_inherited_deletion. + if not self._should_persist() and not self.pluginsettings.allow_inherited_deletion: + return - to_be_deleted = current_ids - expected_ids - for id_to_delete in to_be_deleted: - self.api.hostinterface.delete(id_to_delete) + for current_hostinterface in current_hostinterfaces: + interfaceid = int(current_hostinterface['interfaceid']) + if interfaceid in expected_ids: + continue + identity = ( + int(current_hostinterface.get('type', 0)), + int(current_hostinterface.get('main', 0)), + int(current_hostinterface.get('useip', 0)), + str(current_hostinterface.get('port', '')), + str(current_hostinterface.get('dns', '') or ''), + ) + if identity in expected_identities: + continue + self.api.hostinterface.delete(interfaceid) def sanitize_string(self, input_str, replacement='_'): """ diff --git a/nbxsync/utils/sync/maintenancesync.py b/nbxsync/utils/sync/maintenancesync.py index 2deb04af..7afb91cf 100644 --- a/nbxsync/utils/sync/maintenancesync.py +++ b/nbxsync/utils/sync/maintenancesync.py @@ -9,6 +9,7 @@ ZabbixMaintenanceTagAssignment, ZabbixServerAssignment, ) +from nbxsync.utils.host_binding import get_host_binding from .syncbase import ZabbixSyncBase @@ -118,14 +119,23 @@ def get_hosts(self): result = [] zabbixhostgroup_ct = ContentType.objects.get_for_model(ZabbixHostgroup) for host in ZabbixMaintenanceObjectAssignment.objects.exclude(assigned_object_type=zabbixhostgroup_ct).filter(zabbixmaintenance=self.obj): - object_ct = ContentType.objects.get_for_model(host.assigned_object) - hostid = None - zabbixserver_assignment = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=host.assigned_object_id).first() - if not zabbixserver_assignment: + assigned = host.assigned_object + if assigned is None: continue - - hostid = zabbixserver_assignment.hostid - + # Prefer the durable ZabbixHostBinding: after HostSync migrates + # identity off ZabbixServerAssignment.hostid, reading only the + # assignment left automatic maintenance windows with an empty + # hosts list. + binding = get_host_binding(assigned, self.obj.zabbixserver) + hostid = binding.hostid if binding else None + if not hostid: + object_ct = ContentType.objects.get_for_model(assigned) + zabbixserver_assignment = ZabbixServerAssignment.objects.filter( + zabbixserver=self.obj.zabbixserver, + assigned_object_type=object_ct, + assigned_object_id=assigned.pk, + ).first() + hostid = getattr(zabbixserver_assignment, 'hostid', None) if zabbixserver_assignment else None if not hostid: continue diff --git a/nbxsync/utils/sync/syncbase.py b/nbxsync/utils/sync/syncbase.py index 723a6d1f..9e844683 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,24 +77,54 @@ 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): + params = self.get_create_params() + if not params: + return None try: - # print('Create params:') - # print(self.get_create_params()) - result = self.api_object().create(**self.get_create_params()) - # print('Zabbix result: ') - # print(result) + result = self.api_object().create(**params) return result.get(self.result_key(), [None])[0] except Exception as err: + # Race condition: another concurrent job may have created the + # object between our find_by_name() check and this create(). + # Recover only when Zabbix rejected the parameters (-32602) and + # exactly one object with our name now exists, so a genuine + # validation failure is still reported instead of silently + # adopting an unrelated object. + if self._is_duplicate_error(err): + found_by_name = self.find_by_name() + if len(found_by_name) == 1: + logger.debug(f'{self.__class__.__name__} already existed (race), reusing ID') + return found_by_name[0].get(self.id_field.split('.')[-1]) msg = f'{self.__class__.__name__} creation failed: {err}' raise RuntimeError(msg) + @staticmethod + def _is_duplicate_error(err) -> bool: + """Whether a Zabbix API error reports an already-existing object. + + zabbix_utils raises APIRequestError with the JSON-RPC payload attached. + Duplicates come back as "Invalid params" (-32602), whose data carries + the specific reason, so both the code and the reason are checked. + """ + code = getattr(err, 'code', None) + if code is not None: + try: + if int(code) != -32602: + return False + except (TypeError, ValueError): + return False + detail = ' '.join(str(part) for part in (getattr(err, 'data', ''), getattr(err, 'message', ''), err) if part) + return 'already exists' in detail.lower() + def find_by_name(self): name_key = 'name' if self.name_field: @@ -116,15 +151,23 @@ 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): - # print('Update params:') - # print(self.get_update_params(object_id=kwargs.get('object_id', None))) - result = self.api_object().update(**self.get_update_params(object_id=kwargs.get('object_id', None))) - # print(result) + params = self.get_update_params(object_id=kwargs.get('object_id', None)) + try: + self.api_object().update(**params) + except Exception as err: + # Zabbix 7.0 hostgroup.update (and similar) rejects no-op updates + # where the name is unchanged with -32602 "already exists". The + # object is already in the desired state — treat as success. + if self._is_duplicate_error(err): + logger.debug(f'{self.__class__.__name__} update was a no-op (already desired state)') + return + raise logger.debug(f'Updated {self.__class__.__name__} ID {self.get_id()}') # --- Object-specific methods to override per implementation --- diff --git a/nbxsync/utils/trigger_dependency_sync.py b/nbxsync/utils/trigger_dependency_sync.py index 18f66717..e9920490 100644 --- a/nbxsync/utils/trigger_dependency_sync.py +++ b/nbxsync/utils/trigger_dependency_sync.py @@ -5,9 +5,9 @@ from dcim.models import Interface from dcim.utils import decompile_path_node -from nbxsync.models import ZabbixServerAssignment from nbxsync.settings import get_plugin_settings from nbxsync.utils import ZabbixConnection +from nbxsync.utils.host_binding import iter_managed_hosts logger = logging.getLogger(__name__) @@ -54,15 +54,14 @@ def get_managed_trigger_descriptions(trigger_config=None): def get_server_assignments(device): - object_ct = ContentType.objects.get_for_model(device) - return list( - ZabbixServerAssignment.objects.filter( - assigned_object_type=object_ct, - assigned_object_id=device.pk, - sync_enabled=True, - zabbixserver__sync_enabled=True, - ).select_related('zabbixserver') - ) + """Return managed-host identities for every enabled Zabbix server on this device. + + Uses durable bindings and inherited server assignments. A direct-only + ``assigned_object_id=device.pk`` query misses zero-touch hosts whose + assignment lives on Site/Role, and ``assignment.hostid`` is cleared after + the first binding sync. + """ + return list(iter_managed_hosts(device, require_hostid=False)) def get_host_assignments(device): diff --git a/nbxsync/views/__init__.py b/nbxsync/views/__init__.py index bdd0b29b..dbe39c9c 100644 --- a/nbxsync/views/__init__.py +++ b/nbxsync/views/__init__.py @@ -24,3 +24,4 @@ from .zabbixmaintenanceobjectassignment import * from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroupassignment import * +from .zabbixtemplaterule import * diff --git a/nbxsync/views/hostinfo.py b/nbxsync/views/hostinfo.py index 61bb8b86..218afcd6 100644 --- a/nbxsync/views/hostinfo.py +++ b/nbxsync/views/hostinfo.py @@ -1,14 +1,14 @@ import logging -from django.contrib.contenttypes.models import ContentType from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP -from nbxsync.models import ZabbixServerAssignment from nbxsync.tables import ZabbixEventTable, ZabbixProblemTable from nbxsync.utils import ZabbixConnection +from nbxsync.utils.host_binding import iter_managed_hosts logger = logging.getLogger(__name__) @@ -21,10 +21,10 @@ def _resolve_model_or_404(objtype): return model -def _server_assignments_for(model, pk): - """Return all ZabbixServerAssignments pointing at the given (model, pk).""" - object_ct = ContentType.objects.get_for_model(model) - return ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=pk).select_related('assigned_object_type', 'zabbixserver') +def _managed_hosts_for(model, pk): + """Return managed (server, hostid) identities for the given (model, pk).""" + instance = get_object_or_404(model, pk=pk) + return list(iter_managed_hosts(instance, require_hostid=True)) def _event_row(assignment, event, *, end_time, duration): @@ -58,7 +58,7 @@ def get_context_data(self, objtype, pk, **kwargs): problem_list = [] fetch_errors = [] - for assignment in _server_assignments_for(model, pk): + for assignment in _managed_hosts_for(model, pk): if not assignment.hostid: continue @@ -91,7 +91,7 @@ def get_context_data(self, objtype, pk, **kwargs): event_list = [] fetch_errors = [] - for assignment in _server_assignments_for(model, pk): + for assignment in _managed_hosts_for(model, pk): if not assignment.hostid: continue diff --git a/nbxsync/views/jobs.py b/nbxsync/views/jobs.py index 357a5d1e..7a21b970 100644 --- a/nbxsync/views/jobs.py +++ b/nbxsync/views/jobs.py @@ -1,4 +1,5 @@ from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.utils.translation import gettext_lazy as _ @@ -6,10 +7,9 @@ from django.views.generic import TemplateView from django_rq import get_queue - -from nbxsync.models import ZabbixMaintenance, ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixConfigurationGroup -from nbxsync.utils.cfggroup.resync_zabbixconfiggroupassignment import resync_zabbixconfigurationgroupassignment from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP +from nbxsync.models import ZabbixConfigurationGroup, ZabbixMaintenance, ZabbixProxy, ZabbixProxyGroup, ZabbixServer +from nbxsync.utils.cfggroup.resync_zabbixconfiggroupassignment import resync_zabbixconfigurationgroupassignment __all__ = ( 'ZabbixSyncInfoModalView', @@ -49,12 +49,13 @@ def get(self, request, objtype, pk): raise Http404(_('Unsupported object type: %(objtype)s') % {'objtype': objtype}) instance = get_object_or_404(model, pk=pk) + content_type = ContentType.objects.get_for_model(instance) messages.success(request, _('Sync job enqueued for %(name)s') % {'name': str(instance)}) queue = get_queue('low') queue.enqueue_job( queue.create_job( func='nbxsync.worker.synchost', - args=[instance], + args=[content_type.app_label, content_type.model, instance.pk], timeout=9000, ) ) 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) diff --git a/nbxsync/views/zabbixhostgroup.py b/nbxsync/views/zabbixhostgroup.py index 7a747c1a..89f7cacb 100644 --- a/nbxsync/views/zabbixhostgroup.py +++ b/nbxsync/views/zabbixhostgroup.py @@ -1,10 +1,11 @@ +from django.db.models import Count from netbox.views.generic import BulkDeleteView, BulkImportView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectView from utilities.views import register_model_view from nbxsync.filtersets import ZabbixHostgroupFilterSet from nbxsync.forms import ZabbixHostgroupBulkEditForm, ZabbixHostgroupFilterForm, ZabbixHostgroupForm, ZabbixHostgroupBulkImportForm -from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment -from nbxsync.tables import ZabbixHostgroupObjectViewTable, ZabbixHostgroupTable +from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixTemplateRule +from nbxsync.tables import ZabbixHostgroupObjectViewTable, ZabbixHostgroupTable, ZabbixTemplateRuleHostgroupViewTable __all__ = ( 'ZabbixHostgroupListView', @@ -29,6 +30,16 @@ class ZabbixHostgroupListView(ObjectListView): filterset = ZabbixHostgroupFilterSet filterset_form = ZabbixHostgroupFilterForm + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .annotate( + assignment_count=Count('zabbixhostgroupassignment', distinct=True), + rule_count=Count('zabbixtemplaterules', distinct=True), + ) + ) + @register_model_view(ZabbixHostgroup) class ZabbixHostgroupView(ObjectView): @@ -41,7 +52,7 @@ class ZabbixHostgroupView(ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used + # Direct assignments (Sites/Roles Jinja, Priority tags, …) hostgroupassignments = ZabbixHostgroupAssignment.objects.filter(zabbixhostgroup=instance) if hostgroupassignments: @@ -50,7 +61,20 @@ def get_extra_context(self, request, instance): else: hostgroupassignment_table = None + # TemplateRules that attach this hostgroup when a platform (and optional + # tags) match — e.g. OS/Linux via the Linux TemplateRule. This is a + # second, first-class path; empty "Assigned objects" does not mean unused. + templaterules = ZabbixTemplateRule.objects.filter(zabbixhostgroup=instance).select_related( + 'zabbixtemplate', + ) + if templaterules.exists(): + templaterule_table = ZabbixTemplateRuleHostgroupViewTable(templaterules) + templaterule_table.configure(request) + else: + templaterule_table = None + context['hostgroupassignment_table'] = hostgroupassignment_table + context['templaterule_table'] = templaterule_table return context diff --git a/nbxsync/views/zabbixtemplaterule.py b/nbxsync/views/zabbixtemplaterule.py new file mode 100644 index 00000000..3fac0e5c --- /dev/null +++ b/nbxsync/views/zabbixtemplaterule.py @@ -0,0 +1,56 @@ +from netbox.views.generic import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectView + +from utilities.views import register_model_view + +from nbxsync.filtersets import ZabbixTemplateRuleFilterSet +from nbxsync.forms import ZabbixTemplateRuleBulkEditForm, ZabbixTemplateRuleFilterForm, ZabbixTemplateRuleForm +from nbxsync.models import ZabbixTemplateRule +from nbxsync.tables import ZabbixTemplateRuleTable + +__all__ = ( + 'ZabbixTemplateRuleListView', + 'ZabbixTemplateRuleView', + 'ZabbixTemplateRuleEditView', + 'ZabbixTemplateRuleBulkEditView', + 'ZabbixTemplateRuleDeleteView', + 'ZabbixTemplateRuleBulkDeleteView', +) + + +@register_model_view(ZabbixTemplateRule, name='list') +class ZabbixTemplateRuleListView(ObjectListView): + queryset = ZabbixTemplateRule.objects.select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + table = ZabbixTemplateRuleTable + filterset = ZabbixTemplateRuleFilterSet + filterset_form = ZabbixTemplateRuleFilterForm + + +@register_model_view(ZabbixTemplateRule) +class ZabbixTemplateRuleView(ObjectView): + queryset = ZabbixTemplateRule.objects.select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + + +@register_model_view(ZabbixTemplateRule, 'edit') +class ZabbixTemplateRuleEditView(ObjectEditView): + queryset = ZabbixTemplateRule.objects.select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + form = ZabbixTemplateRuleForm + + +@register_model_view(ZabbixTemplateRule, 'bulk_edit') +class ZabbixTemplateRuleBulkEditView(BulkEditView): + queryset = ZabbixTemplateRule.objects.select_related('zabbixtemplate', 'zabbixhostgroup', 'zabbixtag', 'manufacturer') + filterset = ZabbixTemplateRuleFilterSet + table = ZabbixTemplateRuleTable + form = ZabbixTemplateRuleBulkEditForm + + +@register_model_view(ZabbixTemplateRule, 'delete') +class ZabbixTemplateRuleDeleteView(ObjectDeleteView): + queryset = ZabbixTemplateRule.objects.all() + + +@register_model_view(ZabbixTemplateRule, 'bulk_delete') +class ZabbixTemplateRuleBulkDeleteView(BulkDeleteView): + queryset = ZabbixTemplateRule.objects.all() + filterset = ZabbixTemplateRuleFilterSet + table = ZabbixTemplateRuleTable diff --git a/nbxsync/worker.py b/nbxsync/worker.py index b4a62201..b38f6cbf 100644 --- a/nbxsync/worker.py +++ b/nbxsync/worker.py @@ -1,21 +1,38 @@ import logging +from django.contrib.contenttypes.models import ContentType from django_rq import job -from nbxsync.jobs import * +from nbxsync.jobs import * logger = logging.getLogger('worker') @job('low') -def synchost(instance): +def synchost(app_label, model_name, object_id): + content_type = ContentType.objects.get_by_natural_key(app_label, model_name) + model = content_type.model_class() + if model is None: + logger.warning('Cannot sync removed content type %s.%s', app_label, model_name) + return + + try: + instance = model.objects.get(pk=object_id) + except model.DoesNotExist: + logger.info('Skipping deleted sync target %s.%s:%s', app_label, model_name, object_id) + return + worker = SyncHostJob(instance=instance) worker.run() @job('low') -def deletehost(instance): - worker = DeleteHostJob(instance=instance) +def deletehost(binding_ids): + if isinstance(binding_ids, (list, tuple, set)): + worker = DeleteHostJob(binding_ids=list(binding_ids)) + else: + # Compatibility for jobs queued by versions before durable binding IDs. + worker = DeleteHostJob(instance=binding_ids) worker.run()