diff --git a/netbox_awx_plugin/signals.py b/netbox_awx_plugin/signals.py index 71e597fec08e0d033bb460fc513e9cc304a3c95f..7d0546c5b8cc5bb02e89bafb615dfcaa8c7146c0 100644 --- a/netbox_awx_plugin/signals.py +++ b/netbox_awx_plugin/signals.py @@ -1,71 +1,122 @@ -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_rq import get_queue from dcim.models import Interface, Device, DeviceRole, DeviceType, Site from ipam.models import Prefix from virtualization.models import VirtualMachine +from extras.models import Tag from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from .models import AWXInventory -import logging - - logger = logging.getLogger(__name__) -__all__ = [ - "update_host", -] - - +# Configure queue name from settings queue_name = get_config().QUEUE_MAPPINGS.get("awx", RQ_QUEUE_DEFAULT) - -@receiver(post_save, sender=Site) -@receiver(post_save, sender=DeviceRole) -@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): +def enqueue_task(task_name, inventory, sender, instance): + """ + Helper function to enqueue AWX tasks in the RQ queue. + """ 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.delete_group", **params) - - -@receiver(post_save, sender=Device) -@receiver(post_save, sender=VirtualMachine) -def save_device(sender, instance, created, **kwargs): - device = sender.objects.get(id=instance.id) - if not device.primary_ip4 is None and device.primary_ip4.dns_name: - rq_queue = get_queue(queue_name) - for inventory in AWXInventory.objects.all(): - if inventory.enabled == True: - params = {"inventory": inventory, "sender": sender, "instance": device} - rq_queue.enqueue("netbox_awx_plugin.synchronization.sync_host", **params) - - -@receiver(post_save, sender=Interface) -def save_interface(sender, instance, **kwargs): - save_device(Device, instance.device, **kwargs) + task_params = {"inventory": inventory, "sender": sender, "instance": instance} + logger.debug(f"Enqueuing task '{task_name}' for {sender.__name__} instance {instance.pk}") + rq_queue.enqueue(f"netbox_awx_plugin.synchronization.{task_name}", **task_params) + +def process_inventory_task(task_name, sender, instance): + """ + Processes a task for all enabled AWX inventories. + """ + inventories = AWXInventory.objects.filter(enabled=True) + if not inventories.exists(): + logger.info(f"No enabled AWX inventories found. Skipping {task_name}.") + return + + for inventory in inventories: + enqueue_task(task_name, inventory, sender, instance) + + +### Signal Handlers for Group-Related Models ### +@receiver([post_save], sender=Site) +@receiver([post_save], sender=DeviceRole) +@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 diff --git a/netbox_awx_plugin/synchronization.py b/netbox_awx_plugin/synchronization.py index e5114fdef8b5fbb8ab0b59bf0164f5a7082d9364..f5fd96f14c24dd9b1bd9634ba3c45f543abf5f15 100644 --- a/netbox_awx_plugin/synchronization.py +++ b/netbox_awx_plugin/synchronization.py @@ -3,6 +3,7 @@ from ipam.models import Prefix from dcim.choices import DeviceStatusChoices from virtualization.choices import VirtualMachineStatusChoices from virtualization.models import VirtualMachine +from extras.models import Tag from rest_framework import serializers import json import logging @@ -11,9 +12,16 @@ from .models import AWXInventory 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): def to_representation(self, instance): return { @@ -149,45 +157,83 @@ serializers = { Prefix: PrefixSerializer, Device: DeviceSerializer, VirtualMachine: VMSerializer, + Tag: TagSerializer } def sync_host(inventory, 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 the host doesn't exist, create it. inventory.create_host(serializer.data) host = inventory.get_host(serializer.data["name"]) else: + # If the host exists, update it if necessary. host_serializer = AWXHostSerializer(data=host) if not host_serializer.is_valid() or host_serializer.validated_data != 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: - sync_host_group_association(inventory, host, Site, instance.site) + sync_host_group_association(inventory, host, Site, instance.site, current_groups) if instance.role: - sync_host_group_association(inventory, host, DeviceRole, instance.role) - if sender == Device: - sync_host_group_association(inventory, host, DeviceType, instance.device_type) + sync_host_group_association(inventory, host, DeviceRole, instance.role, current_groups) + if isinstance(instance, Device) and 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) group_name = serializer.data["name"] - groups = [ - group - for group in host["summary_fields"]["groups"]["results"] - if group["name"].startswith(group_prefixes[object_type]) + + # Filter out the groups that match the current object_type (e.g., Site, Tag) + relevant_groups = [ + group for group in current_groups if group["name"].startswith(group_prefixes[object_type]) ] - for group in groups: - if group["name"] != group_name: - inventory.disassociate_host_group(host["id"], group["id"]) - if group_name not in [group["name"] for group in groups]: + + # Check if the host is already in the desired group + if group_name not in [group["name"] for group in relevant_groups]: 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): serializer = serializers[sender](instance) @@ -224,6 +270,9 @@ def sync_all(job): sync_group(inventory, DeviceRole, device_role) for device_type in DeviceType.objects.all(): sync_group(inventory, DeviceType, device_type) + for tag in Tag.objects.all(): + sync_group(inventory, Tag, tag) + for device in Device.objects.all(): if not device.primary_ip4 is None and device.primary_ip4.dns_name: sync_host(inventory, Device, device)