Skip to content
Snippets Groups Projects
Commit 54d2b5f2 authored by Fahrudin Halilovic's avatar Fahrudin Halilovic
Browse files

Add support for Tags as AWX Groups with m2m-changed signal handling, including...

Add support for Tags as AWX Groups with m2m-changed signal handling, including host-group association and disassociation - INFRA-10378
parent f4f57a0e
No related branches found
No related tags found
1 merge request!1Add support for Tags as AWX Groups with m2m-changed signal handling, including...
Pipeline #199594 passed
from django.db.models.signals import post_save, pre_save, post_delete import logging
from django.db.models.signals import post_save, pre_save, post_delete, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django_rq import get_queue from django_rq import get_queue
from dcim.models import Interface, Device, DeviceRole, DeviceType, Site from dcim.models import Interface, Device, DeviceRole, DeviceType, Site
from ipam.models import Prefix from ipam.models import Prefix
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from extras.models import Tag
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from .models import AWXInventory from .models import AWXInventory
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = [ # Configure queue name from settings
"update_host",
]
queue_name = get_config().QUEUE_MAPPINGS.get("awx", RQ_QUEUE_DEFAULT) queue_name = get_config().QUEUE_MAPPINGS.get("awx", RQ_QUEUE_DEFAULT)
def enqueue_task(task_name, inventory, sender, instance):
@receiver(post_save, sender=Site) """
@receiver(post_save, sender=DeviceRole) Helper function to enqueue AWX tasks in the RQ queue.
@receiver(post_save, sender=DeviceType) """
@receiver(post_save, sender=Prefix)
def save_group_instance(sender, instance, created, **kwargs):
rq_queue = get_queue(queue_name)
for inventory in AWXInventory.objects.all():
if inventory.enabled == True:
params = {"inventory": inventory, "sender": sender, "instance": instance}
rq_queue.enqueue("netbox_awx_plugin.synchronization.sync_group", **params)
@receiver(pre_save, sender=Site)
@receiver(pre_save, sender=DeviceRole)
@receiver(pre_save, sender=DeviceType)
@receiver(pre_save, sender=Prefix)
def pre_save_group_instance(sender, instance, **kwargs):
if sender.objects.filter(id=instance.id).exists():
instance.__original_object = sender.objects.get(id=instance.id)
@receiver(post_delete, sender=Site)
@receiver(post_delete, sender=DeviceRole)
@receiver(post_delete, sender=DeviceType)
@receiver(post_delete, sender=Prefix)
def save_group_instance(sender, instance, **kwargs):
rq_queue = get_queue(queue_name) rq_queue = get_queue(queue_name)
for inventory in AWXInventory.objects.all(): task_params = {"inventory": inventory, "sender": sender, "instance": instance}
if inventory.enabled == True: logger.debug(f"Enqueuing task '{task_name}' for {sender.__name__} instance {instance.pk}")
params = {"inventory": inventory, "sender": sender, "instance": instance} rq_queue.enqueue(f"netbox_awx_plugin.synchronization.{task_name}", **task_params)
rq_queue.enqueue("netbox_awx_plugin.synchronization.delete_group", **params)
def process_inventory_task(task_name, sender, instance):
"""
@receiver(post_save, sender=Device) Processes a task for all enabled AWX inventories.
@receiver(post_save, sender=VirtualMachine) """
def save_device(sender, instance, created, **kwargs): inventories = AWXInventory.objects.filter(enabled=True)
device = sender.objects.get(id=instance.id) if not inventories.exists():
if not device.primary_ip4 is None and device.primary_ip4.dns_name: logger.info(f"No enabled AWX inventories found. Skipping {task_name}.")
rq_queue = get_queue(queue_name) return
for inventory in AWXInventory.objects.all():
if inventory.enabled == True: for inventory in inventories:
params = {"inventory": inventory, "sender": sender, "instance": device} enqueue_task(task_name, inventory, sender, instance)
rq_queue.enqueue("netbox_awx_plugin.synchronization.sync_host", **params)
### Signal Handlers for Group-Related Models ###
@receiver(post_save, sender=Interface) @receiver([post_save], sender=Site)
def save_interface(sender, instance, **kwargs): @receiver([post_save], sender=DeviceRole)
save_device(Device, instance.device, **kwargs) @receiver([post_save], sender=DeviceType)
@receiver([post_save], sender=Prefix)
def handle_group_post_save(sender, instance, created, **kwargs):
"""
Handles post-save events for group-related models like Site, DeviceRole, etc.
This synchronizes AWX groups when the object is saved.
"""
process_inventory_task("sync_group", sender, instance)
@receiver([pre_save], sender=Site)
@receiver([pre_save], sender=DeviceRole)
@receiver([pre_save], sender=DeviceType)
@receiver([pre_save], sender=Prefix)
def handle_group_pre_save(sender, instance, **kwargs):
"""
Handles pre-save events for group-related models to store the original instance for comparison.
"""
if instance.pk and sender.objects.filter(pk=instance.pk).exists():
instance._original = sender.objects.get(pk=instance.pk)
logger.debug(f"Original object stored for {sender.__name__} instance {instance.pk}")
@receiver([post_delete], sender=Site)
@receiver([post_delete], sender=DeviceRole)
@receiver([post_delete], sender=DeviceType)
@receiver([post_delete], sender=Prefix)
def handle_group_post_delete(sender, instance, **kwargs):
"""
Handles post-delete events for group-related models to remove AWX groups when objects are deleted.
"""
process_inventory_task("delete_group", sender, instance)
### Signal Handlers for Device and VirtualMachine ###
@receiver([post_save], sender=Device)
@receiver([post_save], sender=VirtualMachine)
def handle_device_post_save(sender, instance, **kwargs):
"""
Handles post-save events for Devices and Virtual Machines.
Synchronizes AWX hosts if the instance meets the IP and DNS requirements.
"""
instance = sender.objects.select_related("primary_ip4").get(pk=instance.pk)
if instance.primary_ip4 and instance.primary_ip4.dns_name:
logger.debug(f"Device/VM {instance.pk} meets IP/DNS criteria. Triggering host sync.")
process_inventory_task("sync_host", sender, instance)
else:
logger.debug(f"Device/VM {instance.pk} does not meet IP/DNS criteria for AWX sync.")
@receiver([post_save], sender=Interface)
def handle_interface_post_save(sender, instance, **kwargs):
"""
Handles post-save events for Interfaces. Delegates synchronization to the associated Device.
"""
if instance.device:
logger.debug(f"Interface {instance.pk} saved, delegating sync to associated Device.")
handle_device_post_save(Device, instance.device, **kwargs)
### Tag Synchronization ###
@receiver(post_save, sender=Tag)
def handle_tag_post_save(sender, instance, created, **kwargs):
"""
Handles post-save events for Tags. Synchronizes AWX groups when a Tag is saved.
"""
process_inventory_task("sync_group", sender, instance)
@receiver(m2m_changed, sender=Device.tags.through)
@receiver(m2m_changed, sender=VirtualMachine.tags.through)
def handle_tag_assignment(sender, instance, action, reverse, model, pk_set, **kwargs):
"""
Handles many-to-many change events (m2m_changed) for Device and VirtualMachine tags.
Associates hosts with AWX groups when tags are assigned.
"""
if action == "post_add":
logger.debug(f"Tags added to {instance}. Syncing host with AWX group.")
process_inventory_task("sync_host", Device if Device.tags.through else VirtualMachine, instance)
if action == "post_remove":
logger.debug(f"Tags removed to {instance}. Syncing host with AWX group.")
process_inventory_task("sync_host", Device if Device.tags.through else VirtualMachine, instance)
\ No newline at end of file
...@@ -3,6 +3,7 @@ from ipam.models import Prefix ...@@ -3,6 +3,7 @@ from ipam.models import Prefix
from dcim.choices import DeviceStatusChoices from dcim.choices import DeviceStatusChoices
from virtualization.choices import VirtualMachineStatusChoices from virtualization.choices import VirtualMachineStatusChoices
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from extras.models import Tag
from rest_framework import serializers from rest_framework import serializers
import json import json
import logging import logging
...@@ -11,9 +12,16 @@ from .models import AWXInventory ...@@ -11,9 +12,16 @@ from .models import AWXInventory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
group_prefixes = {Site: "site_", DeviceRole: "devicerole_", DeviceType: "devicetype_", Prefix: "prefix_"} group_prefixes = {Site: "site_", DeviceRole: "devicerole_", DeviceType: "devicetype_", Prefix: "prefix_", Tag:"tag_"}
class TagSerializer(serializers.BaseSerializer):
def to_representation(self, instance):
return {
"name": "{}{}".format(group_prefixes[Tag], instance.slug.replace("-", "_")),
"variables": json.dumps({"netbox_tag_name": instance.name}),
}
class SiteSerializer(serializers.BaseSerializer): class SiteSerializer(serializers.BaseSerializer):
def to_representation(self, instance): def to_representation(self, instance):
return { return {
...@@ -149,45 +157,83 @@ serializers = { ...@@ -149,45 +157,83 @@ serializers = {
Prefix: PrefixSerializer, Prefix: PrefixSerializer,
Device: DeviceSerializer, Device: DeviceSerializer,
VirtualMachine: VMSerializer, VirtualMachine: VMSerializer,
Tag: TagSerializer
} }
def sync_host(inventory, sender, instance): def sync_host(inventory, sender, instance):
serializer = serializers[sender](instance) serializer = serializers[sender](instance)
host = inventory.get_host(serializer.data["name"]) host = inventory.get_host(serializer.data["name"])
if host is None: if host is None:
# If the host doesn't exist, create it.
inventory.create_host(serializer.data) inventory.create_host(serializer.data)
host = inventory.get_host(serializer.data["name"]) host = inventory.get_host(serializer.data["name"])
else: else:
# If the host exists, update it if necessary.
host_serializer = AWXHostSerializer(data=host) host_serializer = AWXHostSerializer(data=host)
if not host_serializer.is_valid() or host_serializer.validated_data != serializer.data: if not host_serializer.is_valid() or host_serializer.validated_data != serializer.data:
inventory.update_host(host["id"], serializer.data) inventory.update_host(host["id"], serializer.data)
# Sync group associations
current_groups = host["summary_fields"]["groups"]["results"]
# Handle associations for each type (Site, DeviceRole, DeviceType, and Tags)
if instance.site: if instance.site:
sync_host_group_association(inventory, host, Site, instance.site) sync_host_group_association(inventory, host, Site, instance.site, current_groups)
if instance.role: if instance.role:
sync_host_group_association(inventory, host, DeviceRole, instance.role) sync_host_group_association(inventory, host, DeviceRole, instance.role, current_groups)
if sender == Device: if isinstance(instance, Device) and instance.device_type:
sync_host_group_association(inventory, host, DeviceType, instance.device_type) sync_host_group_association(inventory, host, DeviceType, instance.device_type, current_groups)
if tags := instance.tags.all():
for tag in tags:
sync_host_group_association(inventory, host, Tag, tag, current_groups)
# print(Prefix.objects.filter(prefix__net_contains_or_equals=device.primary_ip4.address)) # Disassociate groups that are no longer relevant
disassociate_removed_groups(inventory, host, instance, current_groups)
def sync_host_group_association(inventory, host, object_type, instance): def sync_host_group_association(inventory, host, object_type, instance, current_groups):
"""
Associates the host with the appropriate AWX group.
"""
serializer = serializers[object_type](instance) serializer = serializers[object_type](instance)
group_name = serializer.data["name"] group_name = serializer.data["name"]
groups = [
group # Filter out the groups that match the current object_type (e.g., Site, Tag)
for group in host["summary_fields"]["groups"]["results"] relevant_groups = [
if group["name"].startswith(group_prefixes[object_type]) group for group in current_groups if group["name"].startswith(group_prefixes[object_type])
] ]
for group in groups:
if group["name"] != group_name: # Check if the host is already in the desired group
inventory.disassociate_host_group(host["id"], group["id"]) if group_name not in [group["name"] for group in relevant_groups]:
if group_name not in [group["name"] for group in groups]:
group = inventory.get_group(group_name) group = inventory.get_group(group_name)
inventory.associate_host_group(host["id"], group["id"]) if group:
inventory.associate_host_group(host["id"], group["id"])
logger.info(f"Host {host['name']} associated with group {group_name}.")
def disassociate_removed_groups(inventory, host, instance, current_groups):
"""
Disassociates the host from AWX groups that are no longer relevant.
"""
# Collect the current groups that should remain (based on the instance's attributes)
valid_group_names = set()
if instance.site:
valid_group_names.add(f"{group_prefixes[Site]}{instance.site.slug.replace('-', '_')}")
if instance.role:
valid_group_names.add(f"{group_prefixes[DeviceRole]}{instance.role.slug.replace('-', '_')}")
if isinstance(instance, Device) and instance.device_type:
valid_group_names.add(f"{group_prefixes[DeviceType]}{instance.device_type.slug.replace('-', '_')}")
if tags := instance.tags.all():
valid_group_names.update(
f"{group_prefixes[Tag]}{tag.slug.replace('-', '_')}" for tag in tags
)
# Disassociate from groups that are no longer valid
for group in current_groups:
if group["name"] not in valid_group_names:
inventory.disassociate_host_group(host["id"], group["id"])
logger.info(f"Host {host['name']} disassociated from group {group['name']}.")
def delete_host(inventory, sender, instance): def delete_host(inventory, sender, instance):
serializer = serializers[sender](instance) serializer = serializers[sender](instance)
...@@ -224,6 +270,9 @@ def sync_all(job): ...@@ -224,6 +270,9 @@ def sync_all(job):
sync_group(inventory, DeviceRole, device_role) sync_group(inventory, DeviceRole, device_role)
for device_type in DeviceType.objects.all(): for device_type in DeviceType.objects.all():
sync_group(inventory, DeviceType, device_type) sync_group(inventory, DeviceType, device_type)
for tag in Tag.objects.all():
sync_group(inventory, Tag, tag)
for device in Device.objects.all(): for device in Device.objects.all():
if not device.primary_ip4 is None and device.primary_ip4.dns_name: if not device.primary_ip4 is None and device.primary_ip4.dns_name:
sync_host(inventory, Device, device) sync_host(inventory, Device, device)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment