diff --git a/README.md b/README.md index 092e7113c9655caafba53e15493d7e4a77ca0f1c..0bcf92ec1a7baab2025a40f6c7f005756044e083 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,65 @@ -## netbox-proxmox-sync +# NetBox Proxmox Sync (Plugin) +FIXME: ta feio askasjkaksajks -TODO: explain this properly (also add a changelog) +This NetBox plugin integrates with a Proxmox cluster to import information +about virtual machines (VMs) and containers (CTs) into NetBox's virtualization +tab. -Simple way to import a Proxmox cluster information into NetBox. +## Installation -Steps: -1. Configure -2. Run sync -3. ??? -4. Happiness? +1. Clone the repository into your NetBox installation's plugins directory: + + ```bash + git clone https://github.com/your/repository.git /opt/netbox/netbox/plugins/netbox_proxmox + + Install the required dependencies (if any): + + bash + +pip install -r /opt/netbox/netbox/plugins/netbox_proxmox/requirements.txt + +Add 'netbox_proxmox' to the PLUGINS list in your NetBox configuration file (/opt/netbox/netbox/netbox/configuration.py). + +Perform database migrations to apply the plugin's models to your database: + +bash + +sudo /opt/netbox/netbox/manage.py migrate + +Restart your NetBox application server: + +bash + + sudo systemctl restart netbox + +Configuration + + Configure the plugin by editing the NetBox configuration file (/opt/netbox/netbox/netbox/configuration.py). Here's an example configuration: + + python + + # Proxmox API settings + PROXMOX_HOST = 'https://your-proxmox-host.example.com' + PROXMOX_API_USER = 'your_proxmox_api_user' + PROXMOX_API_PASSWORD = 'your_proxmox_api_password' + + # NetBox settings + NETBOX_PROXMOX_CLUSTER_NAME = 'Your Proxmox Cluster Name' + + Optionally, configure any additional settings required by the plugin. + +Usage + + Once the plugin is installed and configured, navigate to the NetBox web interface. + + Go to the virtualization tab to view imported VM and CT information from your Proxmox cluster. + +Contributing + +Contributions are welcome! Please fork the repository and submit pull requests to contribute improvements or new features. +License + +This plugin is licensed under the GPL-3.0 License. See the LICENSE file for details. +Support + +For support or questions about this plugin, please open an issue on the GitHub repository. diff --git a/build/lib/netbox_proxmox_sync/__init__.py b/build/lib/netbox_proxmox_sync/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29948087aef9ab10f486f6e0f5b76391a0e1e1b0 --- /dev/null +++ b/build/lib/netbox_proxmox_sync/__init__.py @@ -0,0 +1,12 @@ +from netbox.plugins import PluginConfig + + +class NetBoxProxmoxSyncConfig(PluginConfig): + name = 'netbox_proxmox_sync' + verbose_name = 'NetBox Proxmox Sync' + description = 'Import cluster information from Proxmox into NetBox' + version = '2.0.0' + base_url = 'netbox-proxmox-sync' + + +config = NetBoxProxmoxSyncConfig diff --git a/build/lib/netbox_proxmox_sync/api/__init__.py b/build/lib/netbox_proxmox_sync/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/netbox_proxmox_sync/api/proxmox/config.py b/build/lib/netbox_proxmox_sync/api/config.py similarity index 98% rename from netbox_proxmox_sync/api/proxmox/config.py rename to build/lib/netbox_proxmox_sync/api/config.py index 6fcb37e40db0c1f024c9862b81513648471af3b0..66474ce850bcae34dbda607c73f77601e4513e22 100644 --- a/netbox_proxmox_sync/api/proxmox/config.py +++ b/build/lib/netbox_proxmox_sync/api/config.py @@ -62,7 +62,7 @@ NETBOX_DEFAULT_TAG_COLOR = NETBOX_SETTINGS.get('default_tag_color_hex', NETBOX_D # Create connections # TODO: more descriptive errors try: - PROXMOX_API = ProxmoxAPI( + PX_API = ProxmoxAPI( PROXMOX_DOMAIN, user=PROXMOX_USER, port=PROXMOX_PORT, @@ -79,7 +79,7 @@ try: url = f'https://{NETBOX_DOMAIN}:{NETBOX_PORT}' else: url = f'http://{NETBOX_DOMAIN}:{NETBOX_PORT}' - NETBOX_API = NetboxAPI( + NB_API = NetboxAPI( url, token=NETBOX_TOKEN, threading=True, diff --git a/build/lib/netbox_proxmox_sync/api/netbox/create.py b/build/lib/netbox_proxmox_sync/api/netbox/create.py new file mode 100644 index 0000000000000000000000000000000000000000..5c0fc06b501a2d56666fd4ead327d19f019e2d88 --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/netbox/create.py @@ -0,0 +1,135 @@ +# TODO: more descriptive errors +# FIXME: fetch_* is a shit name +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox +from .delete import all as delete_everything + + +def _assert_nodes_exist(proxmox_nodes): + node_names = set([node['name'] for node in proxmox_nodes]) + existing_names = set([ + node.name for node in NB_API.dcim.devices.filter(name=node_names) + ]) + missing_nodes = list(node_names - existing_names) + if len(missing_nodes) > 0: + missing = [f'Node "{node_name}" is missing!' for node_name in missing_nodes] + raise ValidationError('Not all cluster nodes registered in NetBox!', missing) + + +def _assert_cluster_does_not_exist(proxmox_cluster): + cluster_name = proxmox_cluster['name'] + netbox_cluster_list = NB_API.virtualization.clusters.filter(name=cluster_name) + if len(netbox_cluster_list) != 0: + raise ValidationError(f'Virtualization cluster "{cluster_name}" already exists!') + + +def cluster(proxmox_cluster, proxmox_nodes): + try: + # Already formatted the data for this :) + netbox_cluster = NB_API.virtualization.clusters.create(**proxmox_cluster) + except Exception as e: + raise APIError(e) + # Update nodes' cluster + node_names = [node['name'] for node in proxmox_nodes] + netbox_nodes = list(NB_API.dcim.devices.filter(name=[node_names])) + for node in netbox_nodes: + node.cluster = {'name': proxmox_cluster['name']} + if not NB_API.dcim.devices.update(netbox_nodes): + # In case of error "rollback" + netbox_cluster.delete() + raise APIError('Failed to set Nodes\' cluster!') + # Return JSON serializable dict :) + return dict(netbox_cluster) + + +def tags(): + proxmox_tags = Proxmox.fetch_tags() + try: + existing_tags = NB_API.extras.tags.filter(content_types=[ + 'virtualization.virtualmachines', + ]) + except Exception as e: + raise APIError(e) + # Tags for VMs currently should only be managed by this plugin + # TODO: we could add a custom field to mark tags managed by the plugin, for instance + if len(existing_tags) > 0: + names = [tag.name for tag in existing_tags] + errors = [f'Tag "{name}" should not exist!' for name in names] + raise ValidationError('Some VM tags already exist!', errors) + try: + netbox_tags = NB_API.extras.tags.create(proxmox_tags) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(tag) for tag in netbox_tags] + + +def custom_fields(): + try: + vmid_already_exists = len(NB_API.extras.custom_fields.filter( + name='vmid', object_types='virtualization.virtualmachine' + )) > 0 + except Exception as e: + raise APIError(e) + if vmid_already_exists: + raise ValidationError('Custom field "vmid" already exists!') + custom_fields = [{ + 'name': 'vmid', + 'label': 'VMID', + 'description': '[Proxmox] VM/CT ID', + 'ui_editable': 'no', + 'ui_visible': 'always', + 'filter_logic': 'exact', + 'type': 'integer', + 'object_types': ['virtualization.virtualmachine'] + }] + # Create stuff :) + try: + netbox_custom_fields = NB_API.extras.custom_fields.create(custom_fields) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(custom_field) for custom_field in netbox_custom_fields] + + +def vms_and_interfaces(): + proxmox_vms, proxmox_interfaces = Proxmox.fetch_virtual_machines_and_interfaces() + try: + netbox_vms = NB_API.virtualization.virtual_machines.create(proxmox_vms) + netbox_interfaces = NB_API.virtualization.interfaces.create(proxmox_interfaces) + except Exception as e: + raise APIError(e) + # Return JSON serializable lists :) + return [dict(x) for x in netbox_vms], [dict(x) for x in netbox_interfaces] + + +def all(): + proxmox_cluster, proxmox_nodes = Proxmox.fetch_cluster_and_nodes() + _assert_nodes_exist(proxmox_nodes) + _assert_cluster_does_not_exist(proxmox_cluster) + # Leave cluster out first so the first error comes quickly, useful for API + # errors like invalid token for instance, which might be common. + # (netbox takes tooooo long to delete <stuff>, even if <stuff> does not exist) + netbox_cluster = cluster(proxmox_cluster, proxmox_nodes) + # In case the cluster() call succeeds we are sure that we have write access + # for the API, so any error that comes out of the following calls must be + # something else (or you messed up things). + # This is useful because we can be pretty sure that a "rollback" is possible + # (the delete_everything() call below *should* NOT raise an exception). + try: + netbox_tags = tags() + netbox_custom_fields = custom_fields() + netbox_vms, netbox_interfaces = vms_and_interfaces() + except Exception as e: + # "rollback" in case of any failure + # (if this function fails you probably messed up in a very interesting way) + delete_everything() + raise e + return { + 'cluster': netbox_cluster, + 'tags': netbox_tags, + 'custom_fields': netbox_custom_fields, + 'vms': netbox_vms, + 'interfaces': netbox_interfaces, + } diff --git a/build/lib/netbox_proxmox_sync/api/netbox/delete.py b/build/lib/netbox_proxmox_sync/api/netbox/delete.py new file mode 100644 index 0000000000000000000000000000000000000000..c01a199a2401b322f5af8c0ce9173aa9d441b1fe --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/netbox/delete.py @@ -0,0 +1,91 @@ +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox + + +def cluster(proxmox_cluster): + cluster_name = proxmox_cluster['name'] + try: + netbox_clusters = NB_API.virtualization.clusters.filter(name=cluster_name) + except Exception as e: + raise APIError(e) + if (len(netbox_clusters)) == 0: + raise ValidationError(f'Cluster "{cluster_name}" does not exist!') + netbox_cluster = list(netbox_clusters)[0] + # Return JSON serializable dict :) + return dict(netbox_cluster) + + +def tags(): + try: + netbox_tags = NB_API.extras.tags.filter(content_types=[ + 'virtualization.virtualmachines', + ]) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(tag) for tag in netbox_tags] + + +def custom_fields(): + try: + netbox_custom_fields = NB_API.extras.custom_fields.filter( + name='vmid', + object_types='virtualization.virtualmachine', + ) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(custom_field) for custom_field in netbox_custom_fields] + + +def vms_and_interfaces(netbox_cluster): + try: + cluster_id = netbox_cluster['id'] + netbox_vms = NB_API.virtualization.virtual_machines.filter(cluster_id=cluster_id) + netbox_interfaces = NB_API.virtualization.interfaces.filter(cluster_id=cluster_id) + except Exception as e: + raise APIError(e) + # Return JSON serializable lists :) + return [dict(x) for x in netbox_vms], [dict(x) for x in netbox_interfaces] + + +def delete_stuff(cluster, tags, custom_fields, vms, interfaces): + # This order is important + rt = NB_API.extras.tags.delete([tag['id'] for tag in tags]) + rc = NB_API.extras.custom_fields.delete([field['id'] for field in custom_fields]) + ri = NB_API.virtualization.interfaces.delete([i['id'] for i in interfaces]) + rv = NB_API.virtualization.virtual_machines.delete([vm['id'] for vm in vms]) + rf = NB_API.virtualization.clusters.delete([cluster['id']]) + errors = [] + if rt is None: + errors.append('Could not delete tags!') + if rf is None: + errors.append('Could not delete custom_fields!') + if rc is None: + errors.append('Could not delete cluster!') + if rv is None: + errors.append('Could not delete virtual machines!') + if ri is None: + errors.append('Could not delete interfaces!') + if len(errors) > 0: + raise APIError('Could not delete plugin information!', errors=errors) + + +def all(): + proxmox_cluster, _ = Proxmox.fetch_cluster_and_nodes() + netbox_cluster = cluster(proxmox_cluster) + netbox_tags = tags() + netbox_custom_fields = custom_fields() + netbox_vms, netbox_interfaces = vms_and_interfaces(netbox_cluster) + delete_stuff( + netbox_cluster, netbox_tags, netbox_custom_fields, + netbox_vms, netbox_interfaces + ) + return { + 'cluster': netbox_cluster, + 'tags': netbox_tags, + 'custom_fields': netbox_custom_fields, + 'vms': netbox_vms, + 'interfaces': netbox_interfaces, + } diff --git a/build/lib/netbox_proxmox_sync/api/netbox/extract.py b/build/lib/netbox_proxmox_sync/api/netbox/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..077b70391c5f8052fbcb8a1b3d329ba2da722ec2 --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/netbox/extract.py @@ -0,0 +1,55 @@ +from netbox_proxmox_sync.api.config import ( + NB_API, + NETBOX_SITE_ID, + NETBOX_CLUSTER_TYPE_ID, +) +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.utils.models import ( + tag_from_netbox, + vm_from_netbox, + interface_from_netbox, +) + + +class NetBox: + @staticmethod + def fetch_cluster(cluster_name): + try: + netbox_cluster = NB_API.virtualization.clusters.filter(name=cluster_name) + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + return { + 'id': netbox_cluster.id, + 'name': netbox_cluster.name, + 'type': NETBOX_CLUSTER_TYPE_ID, + 'description': netbox_cluster.description, + 'site': NETBOX_SITE_ID, + } + + @staticmethod + def fetch_tags(): + try: + netbox_tags = [dict(tag) for tag in NB_API.extras.tags.filter( + content_types='virtualization.virtualmachines', + )] + except Exception as e: + raise APIError(e) + + return [tag_from_netbox(tag) for tag in netbox_tags] + + @staticmethod + def fetch_virtual_machines_and_interfaces(cluster_id): + try: + netbox_vms = [ + dict(vm) for vm in NB_API.virtualization.virtual_machines.filter( + cluster_id=cluster_id + ) + ] + netbox_interfaces = [dict(i) for i in NB_API.virtualization.interfaces.all()] + except Exception as e: + raise APIError(e) + + vms = [vm_from_netbox(vm) for vm in netbox_vms] + interfaces = [interface_from_netbox(i) for i in netbox_interfaces] + + return vms, interfaces diff --git a/build/lib/netbox_proxmox_sync/api/netbox/update.py b/build/lib/netbox_proxmox_sync/api/netbox/update.py new file mode 100644 index 0000000000000000000000000000000000000000..368105da90e4222fd8f100273682e689f1e64639 --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/netbox/update.py @@ -0,0 +1,123 @@ +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox +from netbox_proxmox_sync.api.netbox.extract import NetBox + + +# MERGE, get created_*, updated_*, deleted_* +def tags(): + proxmox_tags = Proxmox.fetch_tags() + netbox_tags = NetBox.fetch_tags() + px_tags = {tag['name']: tag for tag in proxmox_tags} + nb_tags = {tag['name']: tag for tag in netbox_tags} + # "merge" information, extract stuff to C/U/D + created = [tag for tag in proxmox_tags if tag['name'] not in nb_tags] + deleted = [tag for tag in netbox_tags if tag['name'] not in px_tags] + updated = [] + for name in px_tags: + # only thing that can really change for tags is their color + if name in nb_tags and px_tags[name]['color'] != nb_tags[name]['color']: + nb_tags[name]['color'] = px_tags[name]['color'] + updated.append(nb_tags[name]) + # FIXME: error handling + NB_API.extras.tags.create(created) + NB_API.extras.tags.update(updated) + NB_API.extras.tags.delete([d['id'] for d in deleted]) + return created, updated, deleted + + +# TODO: allow for updates of custom fields in case the plugin is updated to use more +# def custom_fields(): + + +def vms(proxmox_vms, netbox_vms): + px_vms = {vm['custom_fields']['vmid']: vm for vm in proxmox_vms} + nb_vms = {vm['custom_fields']['vmid']: vm for vm in netbox_vms} + # "merge" information, extract stuff to C/U/D + created = [vm for vm in proxmox_vms if vm['custom_fields']['vmid'] not in nb_vms] + deleted = [vm for vm in netbox_vms if vm['custom_fields']['vmid'] not in px_vms] + updated = [] + for name in px_vms: + changed = False + px_vm = px_vms[name] + if nb_vms.get(name) is None: + continue + nb_vm = nb_vms[name] + # Update all fields + for field in px_vm: + changed |= nb_vm[field] != px_vm[field] + nb_vm[field] = px_vm[field] + if changed: + updated.append(nb_vm) + return created, updated, deleted + + +def interfaces(proxmox_interfaces, netbox_interfaces): + # Note: if the MAC changes the IP for the VM is lost :) + px_interfaces = {i['mac_address']: i for i in proxmox_interfaces} + nb_interfaces = {i['mac_address']: i for i in netbox_interfaces} + created = [i for i in proxmox_interfaces if i['mac_address'] not in nb_interfaces] + deleted = [i for i in netbox_interfaces if i['mac_address'] not in px_interfaces] + updated = [] + for name in px_interfaces: + changed = False + # Update all fields + px_interface = px_interfaces[name] + if nb_interfaces.get(name) is None: + continue + nb_interface = nb_interfaces[name] + for field in px_interface: + changed |= nb_interface[field] != px_interface[field] + nb_interface[field] = px_interface[field] + if changed: + updated.append(nb_interface) + return created, updated, deleted + + +def vms_and_interfaces(cluster_id): + proxmox_vms, proxmox_interfaces = Proxmox.fetch_virtual_machines_and_interfaces() + netbox_vms, netbox_interfaces = NetBox.fetch_virtual_machines_and_interfaces(cluster_id) + + created_vms, updated_vms, deleted_vms = vms(proxmox_vms, netbox_vms) + created_i, updated_i, deleted_i = interfaces(proxmox_interfaces, netbox_interfaces) + + # FIXME: error handling + NB_API.virtualization.virtual_machines.create(created_vms) + NB_API.virtualization.virtual_machines.update(updated_vms) + NB_API.virtualization.virtual_machines.delete([d['id'] for d in deleted_vms]) + NB_API.virtualization.interfaces.create(created_i) + NB_API.virtualization.interfaces.update(updated_i) + # NB_API.virtualization.interfaces.delete([d['id'] for d in deleted]) + + return created_vms, updated_vms, deleted_vms, \ + created_i, updated_i, deleted_i + + +def all(): + proxmox_cluster = Proxmox.fetch_cluster() + cluster_name = proxmox_cluster['name'] + clusters = list(NB_API.virtualization.clusters.filter(name=cluster_name)) + if (len(clusters)) == 0: + raise ValidationError(f'Cluster "{cluster_name}" does not exist!') + netbox_cluster = clusters[0] + created_tags, updated_tags, deleted_tags = tags() + created_vms, updated_vms, deleted_vms, \ + created_interfaces, updated_interfaces, deleted_interfaces = \ + vms_and_interfaces(cluster_id=netbox_cluster.id) + return { + 'tags': { + 'created': created_tags, + 'updated': updated_tags, + 'deleted': deleted_tags, + }, + 'virtual_machines': { + 'created': created_vms, + 'updated': updated_vms, + 'deleted': deleted_vms, + }, + 'interfaces': { + 'created': created_interfaces, + 'updated': updated_interfaces, + 'deleted': deleted_interfaces, + } + } diff --git a/build/lib/netbox_proxmox_sync/api/proxmox/extract.py b/build/lib/netbox_proxmox_sync/api/proxmox/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..34590706509b564cc57125f92c70cf54f4b26c4a --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/proxmox/extract.py @@ -0,0 +1,119 @@ +from netbox_proxmox_sync.api.config import ( + PX_API, + NETBOX_SITE_ID, + NETBOX_CLUSTER_DESCRIPTION, + NETBOX_CLUSTER_TYPE_ID, +) +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.utils.models import ( + tag_from_proxmox, + vm_from_proxmox, + interface_from_proxmox, +) + + +# Utils for proper data extraction +def extract_vm_data(cluster_name, node_name, vm_status): + try: + vm_config = PX_API.nodes(node_name).qemu(vm_status['vmid']).config.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + tags = vm_config['tags'].split(';') + vm_data = vm_from_proxmox(cluster_name, node_name, vm_status, tags) + interfaces_data = extract_vm_interfaces(vm_config) + + return vm_data, interfaces_data + + +def extract_vm_interfaces(proxmox_vm_config): + vm_name = proxmox_vm_config['name'] + interfaces = [] + for interface_name in [key for key in proxmox_vm_config if key.startswith('net')]: + interface_info = proxmox_vm_config[interface_name] + new_interface = interface_from_proxmox(vm_name, interface_name, interface_info) + interfaces.append(new_interface) + return interfaces + + +class Proxmox: + @staticmethod + def fetch_cluster(): + cluster, _ = Proxmox.fetch_cluster_and_nodes() + return cluster + + @staticmethod + def fetch_cluster_and_nodes(): + try: + proxmox_cluster_info = PX_API.cluster.status.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + cluster = { + 'name': proxmox_cluster_info[0]['name'], + 'type': NETBOX_CLUSTER_TYPE_ID, + 'description': NETBOX_CLUSTER_DESCRIPTION, + 'site': NETBOX_SITE_ID, + } + return cluster, proxmox_cluster_info[1:] + + @staticmethod + def fetch_tags(): + try: + proxmox_tag_info = PX_API.cluster.options.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + allowed_tags = proxmox_tag_info['allowed-tags'] + tag_colormap = proxmox_tag_info['tag-style']['color-map'].split(';') + tags = [] + # Create tags with no color defined + for tag_name in allowed_tags: + tags.append(tag_from_proxmox(tag_name)) + # Remap defined tag colors + for tag_info in tag_colormap: + tag_name = tag_info.split(':')[0] + tag_slug = tag_name.lower().replace(' ', '-').replace('.', '_') + tag_color = tag_info.split(':')[1].lower() + found = False + # Find existing tag and update its color + for tag in tags: + if tag['name'] == tag_name: + found = True + tag['color'] = tag_color + break + # ??? + if not found: + tags.append({ + 'name': tag_name, + 'slug': tag_slug, + 'color': tag_color, + 'object_types': ['virtualization.virtualmachine'] + }) + return tags + + @staticmethod + def fetch_virtual_machines_and_interfaces(): + try: + cluster_status = PX_API.cluster.status.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + cluster_name = cluster_status[0]['name'] + proxmox_nodes = [node['name'] for node in cluster_status[1:]] + # List, because node information is stored in the VMs themselves + # (same for interfaces: vm info is stored in the interface itself) + virtual_machines = [] + interfaces = [] + for node_name in proxmox_nodes: + try: + node_vms = PX_API.nodes(node_name).qemu.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + for vm_status in node_vms: + # If the update runs as the VM is being cloned the vm_config will be + # messed up and our update will fail. + if vm_status.get('lock') is not None and vm_status['lock'] == 'clone': + continue + new_vm, new_interfaces = extract_vm_data( + cluster_name, node_name, vm_status + ) + virtual_machines.append(new_vm) + interfaces.extend(new_interfaces) + return virtual_machines, interfaces diff --git a/build/lib/netbox_proxmox_sync/api/urls.py b/build/lib/netbox_proxmox_sync/api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..5acc2eee3044bd82d59eb564322762d26f45a688 --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = 'netbox_proxmox_sync' + +urlpatterns = [ + path('create/', views.CreateCluster.as_view(), name='proxmoxsync_create'), + path('update/', views.UpdateCluster.as_view(), name='proxmoxsync_update'), + path('delete/', views.DeleteCluster.as_view(), name='proxmoxsync_delete'), +] diff --git a/netbox_proxmox_sync/api/errors.py b/build/lib/netbox_proxmox_sync/api/utils/errors.py similarity index 74% rename from netbox_proxmox_sync/api/errors.py rename to build/lib/netbox_proxmox_sync/api/utils/errors.py index 2b49c48ff96402ed5b40e5a8851052f8cde343be..9b16ad5edb3ade134a6a7d39aa5c876c4ed1e07d 100644 --- a/netbox_proxmox_sync/api/errors.py +++ b/build/lib/netbox_proxmox_sync/api/utils/errors.py @@ -1,13 +1,13 @@ class APIError(Exception): - def __init__(self, message, status=500): + def __init__(self, message, status=500, errors=[]): super().__init__(message) self.status = status + self.errors = errors class ValidationError(APIError): def __init__(self, message, errors=[]): - super().__init__(message, 422) - self.errors = errors + super().__init__(message, 422, errors) class UnauthorizedError(APIError): diff --git a/build/lib/netbox_proxmox_sync/api/utils/models.py b/build/lib/netbox_proxmox_sync/api/utils/models.py new file mode 100644 index 0000000000000000000000000000000000000000..cd974a3aaf0716c056911b5899562b666d33e9cd --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/utils/models.py @@ -0,0 +1,167 @@ +from netbox_proxmox_sync.api.config import ( + NETBOX_DEFAULT_TAG_COLOR, + NETBOX_VM_ROLE_ID, +) +# Classes to ensure NetBox and Proxmox data have the exact same format and fields, +# which makes updating stuff correctly easier + + +class VirtualMachine: + def __init__(self, vmid, name, status, node_name, cluster_name, vcpus, maxmem, + role_id, tags=[], maxdisk=None, description=None, id=None): + self.vmid = vmid + self.name = name + self.status = status + self.node_name = node_name + self.cluster_name = cluster_name + self.role_id = role_id + self.vcpus = vcpus + self.maxmem = maxmem + self.tags = sorted(tags) + self.maxdisk = maxdisk + self.description = description + self.id = id + + def to_dict(self): + # Trust me, it's important that they're sorted + vm_data = { + 'name': self.name, + 'status': self.status, + 'device': {'name': self.node_name}, + 'cluster': {'name': self.cluster_name}, + 'vcpus': self.vcpus, + 'memory': self.maxmem, + 'role': self.role_id, + 'tags': [{'name': tag} for tag in self.tags], + 'custom_fields': {'vmid': self.vmid} + } + # Both these fields may be None, so we have to set them separately + if self.maxdisk is not None and self.maxdisk > 0: + vm_data['disk'] = int(self.maxdisk) + if self.description is not None: + # NetBox only allows 200 char description, but our VMs have more + # so we store the description in the "comments" instead + vm_data['comments'] = self.description + if self.id is not None: + vm_data['id'] = self.id + return vm_data + + +class VirtualInterface: + def __init__(self, name, vm_name, mac_address, vlan_id, id=None): + self.name = name + self.vm_name = vm_name + self.mac_address = mac_address + self.vlan_id = vlan_id + self.id = id + + def to_dict(self): + interface = { + 'name': self.name, + 'virtual_machine': {'name': self.vm_name}, + 'mac_address': self.mac_address, + 'mode': 'access', + 'untagged_vlan': {'vid': self.vlan_id}, + # 'bridge': bridge + } + if self.id is not None: + interface['id'] = self.id + return interface + + +class VirtualMachineTag: + def __init__(self, name, id=None, color=NETBOX_DEFAULT_TAG_COLOR): + self.name = name + self.slug = name.lower().replace(' ', '-').replace('.', '_') + self.color = color + self.id = id + + def to_dict(self): + vm_tag = { + 'name': self.name, + 'slug': self.slug, + 'color': self.color, + 'object_types': ['virtualization.virtualmachine'], + } + if self.id is not None: + vm_tag['id'] = self.id + return vm_tag + + +def tag_from_netbox(netbox_tag): + return VirtualMachineTag( + id=netbox_tag['id'], + name=netbox_tag['name'], + # slug=netbox_tag.slug, + color=netbox_tag['color'], + ).to_dict() + + +def vm_from_netbox(netbox_vm): + role_id = None + if netbox_vm.get('role'): + role_id = netbox_vm['role']['id'] + return VirtualMachine( + id=netbox_vm['id'], + vmid=netbox_vm['custom_fields']['vmid'], + name=netbox_vm['name'], + status=netbox_vm['status']['value'], + # FIXME: some of these are tricky + node_name=netbox_vm['device']['name'], + cluster_name=netbox_vm['cluster']['name'], + role_id=role_id, + vcpus=netbox_vm['vcpus'], + maxmem=netbox_vm['memory'], + tags=[tag['name'] for tag in netbox_vm['tags']], + maxdisk=netbox_vm.get('disk'), + description=netbox_vm.get('comments'), + ).to_dict() + + +def interface_from_netbox(netbox_interface): + return VirtualInterface( + id=netbox_interface['id'], + name=netbox_interface['name'], + vm_name=netbox_interface['virtual_machine']['name'], + mac_address=netbox_interface['mac_address'].upper(), + vlan_id=netbox_interface['untagged_vlan']['vid'], + ).to_dict() + + +def tag_from_proxmox(tag_name, color=NETBOX_DEFAULT_TAG_COLOR): + return VirtualMachineTag( + tag_name, + color, + ).to_dict() + + +def vm_from_proxmox(cluster_name, proxmox_node_name, proxmox_vm, tags=[]): + maxdisk = proxmox_vm.get('maxdisk') + if maxdisk is not None: + maxdisk = int(maxdisk) / 2 ** 30 # B -> GB + memory = int(proxmox_vm['maxmem']) / 2**20 # B -> MB + return VirtualMachine( + vmid=proxmox_vm['vmid'], + name=proxmox_vm['name'], + status='active' if proxmox_vm['status'] == 'running' else 'offline', + node_name=proxmox_node_name, + cluster_name=cluster_name, + vcpus=proxmox_vm['cpus'], + role_id=NETBOX_VM_ROLE_ID, + maxmem=memory, + tags=tags, + maxdisk=maxdisk, + description=proxmox_vm.get('description'), + ).to_dict() + + +def interface_from_proxmox(proxmox_vm_name, interface_name, proxmox_interface): + # net[0-9]+: 'virtio=00:00:00:00:00:00,bridge=vmbr<VID>' + mac = proxmox_interface.split('virtio=')[1].split(',')[0] + vlan_id = int(proxmox_interface.split('bridge=vmbr')[1].split(',firewall')[0]) + return VirtualInterface( + name=f'{proxmox_vm_name}:{interface_name}', + vm_name=proxmox_vm_name, + mac_address=mac.upper(), + vlan_id=vlan_id, + ).to_dict() diff --git a/build/lib/netbox_proxmox_sync/api/views.py b/build/lib/netbox_proxmox_sync/api/views.py new file mode 100644 index 0000000000000000000000000000000000000000..67f07669dd8969fbe3ec7e8bdf8931c632eeb9ef --- /dev/null +++ b/build/lib/netbox_proxmox_sync/api/views.py @@ -0,0 +1,57 @@ +from django.http import HttpResponse +from django.views import View +from django.contrib.auth.mixins import PermissionRequiredMixin +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.netbox import create, update, delete +import json +import traceback +# TODO: fix HTTP status codes +# TODO: proper error handling + + +class CreateCluster(PermissionRequiredMixin, View): + permission_required = "netbox_proxbox.sync_proxmox_cluster" + + def get(self, _): + try: + result = create.all() + json_result = json.dumps(result) + return HttpResponse(json_result, content_type='application/json') + except APIError as e: + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) + return HttpResponse( + json_result, status=e.status, content_type='application/json' + ) + + +class UpdateCluster(PermissionRequiredMixin, View): + permission_required = "netbox_proxbox.sync_proxmox_cluster" + + def get(self, _): + try: + result = update.all() + json_result = json.dumps(result) + return HttpResponse(json_result, status=201, content_type='application/json') + except APIError as e: + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) + return HttpResponse( + json_result, status=e.status, content_type='application/json' + ) + + +class DeleteCluster(PermissionRequiredMixin, View): + permission_required = "netbox_proxbox.reset_promox_cluster" + + def get(self, _): + try: + result = delete.all() + json_result = json.dumps(result) + return HttpResponse(json_result, status=201, content_type='application/json') + except APIError as e: + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) + return HttpResponse( + json_result, status=e.status, content_type='application/json' + ) diff --git a/netbox_proxmox_sync.egg-info/PKG-INFO b/netbox_proxmox_sync.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..2169862fc9d7a335baf32ee5de0bf6fb1b3f851c --- /dev/null +++ b/netbox_proxmox_sync.egg-info/PKG-INFO @@ -0,0 +1,4 @@ +Metadata-Version: 2.1 +Name: netbox-proxmox-sync +Version: 2.0.0 +Summary: Import Proxmox cluster info into NetBox. diff --git a/netbox_proxmox_sync.egg-info/SOURCES.txt b/netbox_proxmox_sync.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..d91c4afd16667b62eaaa32a10638c4d2bf362f46 --- /dev/null +++ b/netbox_proxmox_sync.egg-info/SOURCES.txt @@ -0,0 +1,20 @@ +README.md +setup.py +netbox_proxmox_sync/__init__.py +netbox_proxmox_sync.egg-info/PKG-INFO +netbox_proxmox_sync.egg-info/SOURCES.txt +netbox_proxmox_sync.egg-info/dependency_links.txt +netbox_proxmox_sync.egg-info/not-zip-safe +netbox_proxmox_sync.egg-info/requires.txt +netbox_proxmox_sync.egg-info/top_level.txt +netbox_proxmox_sync/api/__init__.py +netbox_proxmox_sync/api/config.py +netbox_proxmox_sync/api/urls.py +netbox_proxmox_sync/api/views.py +netbox_proxmox_sync/api/netbox/create.py +netbox_proxmox_sync/api/netbox/delete.py +netbox_proxmox_sync/api/netbox/extract.py +netbox_proxmox_sync/api/netbox/update.py +netbox_proxmox_sync/api/proxmox/extract.py +netbox_proxmox_sync/api/utils/errors.py +netbox_proxmox_sync/api/utils/models.py \ No newline at end of file diff --git a/netbox_proxmox_sync.egg-info/dependency_links.txt b/netbox_proxmox_sync.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/netbox_proxmox_sync.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/netbox_proxmox_sync.egg-info/not-zip-safe b/netbox_proxmox_sync.egg-info/not-zip-safe new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/netbox_proxmox_sync.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/netbox_proxmox_sync.egg-info/requires.txt b/netbox_proxmox_sync.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..3240c1cb61a0381a70c378909e3c5be729663ab4 --- /dev/null +++ b/netbox_proxmox_sync.egg-info/requires.txt @@ -0,0 +1,2 @@ +proxmoxer +pynetbox diff --git a/netbox_proxmox_sync.egg-info/top_level.txt b/netbox_proxmox_sync.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..0bd3fead4416e20df81238981c3bbeae84dcb467 --- /dev/null +++ b/netbox_proxmox_sync.egg-info/top_level.txt @@ -0,0 +1 @@ +netbox_proxmox_sync diff --git a/netbox_proxmox_sync/__init__.py b/netbox_proxmox_sync/__init__.py index ece8f09482f91b00bc96784f82e7deb4733e6bd5..29948087aef9ab10f486f6e0f5b76391a0e1e1b0 100644 --- a/netbox_proxmox_sync/__init__.py +++ b/netbox_proxmox_sync/__init__.py @@ -5,7 +5,7 @@ class NetBoxProxmoxSyncConfig(PluginConfig): name = 'netbox_proxmox_sync' verbose_name = 'NetBox Proxmox Sync' description = 'Import cluster information from Proxmox into NetBox' - version = '1.0.2' + version = '2.0.0' base_url = 'netbox-proxmox-sync' diff --git a/netbox_proxmox_sync/api/config.py b/netbox_proxmox_sync/api/config.py new file mode 100644 index 0000000000000000000000000000000000000000..66474ce850bcae34dbda607c73f77601e4513e22 --- /dev/null +++ b/netbox_proxmox_sync/api/config.py @@ -0,0 +1,88 @@ +from proxmoxer import ProxmoxAPI +from pynetbox import api as NetboxAPI +from netbox.settings import PLUGINS_CONFIG + + +# Set default values +NETBOX_DEFAULT_CONFIG = { + 'domain': 'localhost', + 'port': 8001, + 'token': 'this should be set', + 'ssl': True, + 'settings': { + 'vm_role_id': 0, + 'site_id': 0, + 'cluster_type_id': 0, + 'cluster_description': 'A Proxmox Cluster.', + 'default_tag_color_hex': 'ffffff', + }, +} +PROXMOX_DEFAULT_CONFIG = { + 'domain': 'this should be set', + 'port': 8006, + 'user': 'this should be set', + 'token': { + 'name': 'this should be set', + 'value': 'this should be set', + }, + 'ssl': True, +} + +USER_PLUGINS_CONFIG = PLUGINS_CONFIG.get('netbox_proxmox_sync', {}) +PROXMOX_CONFIG = USER_PLUGINS_CONFIG.get('proxmox', {}) +NETBOX_CONFIG = USER_PLUGINS_CONFIG.get('netbox', {}) + +# TODO: throw errors for missing required fields (like token/user/etc) +# -> honestly... every field is required here (except maybe ssl) +# PROXMOX +# - Main +PROXMOX_DOMAIN = PROXMOX_CONFIG.get('domain', PROXMOX_DEFAULT_CONFIG['domain']) +PROXMOX_PORT = PROXMOX_CONFIG.get('port', PROXMOX_DEFAULT_CONFIG['port']) +PROXMOX_USER = PROXMOX_CONFIG.get('user', PROXMOX_DEFAULT_CONFIG['user']) +PROXMOX_SSL = PROXMOX_CONFIG.get('ssl', PROXMOX_DEFAULT_CONFIG['ssl']) +# - Token +PROXMOX_TOKEN = PROXMOX_CONFIG.get('token', PROXMOX_DEFAULT_CONFIG['token']) +PROXMOX_TOKEN_NAME = PROXMOX_TOKEN.get('name', PROXMOX_DEFAULT_CONFIG['token']['name']) +PROXMOX_TOKEN_VALUE = PROXMOX_TOKEN.get('value', PROXMOX_DEFAULT_CONFIG['token']['value']) + +# NETBOX +# - Main +NETBOX_DOMAIN = NETBOX_CONFIG.get('domain', NETBOX_DEFAULT_CONFIG['domain']) +NETBOX_PORT = NETBOX_CONFIG.get('port', NETBOX_DEFAULT_CONFIG['port']) +NETBOX_TOKEN = NETBOX_CONFIG.get('token', NETBOX_DEFAULT_CONFIG['token']) +NETBOX_SSL = NETBOX_CONFIG.get('ssl', NETBOX_DEFAULT_CONFIG['ssl']) +# - Settings +NETBOX_SETTINGS = NETBOX_CONFIG.get('settings', NETBOX_DEFAULT_CONFIG['settings']) +NETBOX_CLUSTER_TYPE_ID = NETBOX_SETTINGS.get('cluster_type_id', NETBOX_DEFAULT_CONFIG['settings']['cluster_type_id']) +NETBOX_CLUSTER_DESCRIPTION = NETBOX_SETTINGS.get('cluster_description', NETBOX_DEFAULT_CONFIG['settings']['cluster_description']) +NETBOX_VM_ROLE_ID = NETBOX_SETTINGS.get('vm_role_id', NETBOX_DEFAULT_CONFIG['settings']['vm_role_id']) +NETBOX_SITE_ID = NETBOX_SETTINGS.get('site_id', NETBOX_DEFAULT_CONFIG['settings']['site_id']) +NETBOX_DEFAULT_TAG_COLOR = NETBOX_SETTINGS.get('default_tag_color_hex', NETBOX_DEFAULT_CONFIG['settings']['default_tag_color_hex']) + +# Create connections +# TODO: more descriptive errors +try: + PX_API = ProxmoxAPI( + PROXMOX_DOMAIN, + user=PROXMOX_USER, + port=PROXMOX_PORT, + token_name=PROXMOX_TOKEN_NAME, + token_value=PROXMOX_TOKEN_VALUE, + verify_ssl=PROXMOX_SSL, + ) +except Exception: + raise RuntimeError('Could not connect to Proxmox Cluster! Verify your credentials!') + +try: + # TODO: allow to change default base path? + if NETBOX_SSL: + url = f'https://{NETBOX_DOMAIN}:{NETBOX_PORT}' + else: + url = f'http://{NETBOX_DOMAIN}:{NETBOX_PORT}' + NB_API = NetboxAPI( + url, + token=NETBOX_TOKEN, + threading=True, + ) +except Exception: + raise RuntimeError('Could not connect to NetBox! Verify your credentials!') diff --git a/netbox_proxmox_sync/api/netbox/create.py b/netbox_proxmox_sync/api/netbox/create.py new file mode 100644 index 0000000000000000000000000000000000000000..5c0fc06b501a2d56666fd4ead327d19f019e2d88 --- /dev/null +++ b/netbox_proxmox_sync/api/netbox/create.py @@ -0,0 +1,135 @@ +# TODO: more descriptive errors +# FIXME: fetch_* is a shit name +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox +from .delete import all as delete_everything + + +def _assert_nodes_exist(proxmox_nodes): + node_names = set([node['name'] for node in proxmox_nodes]) + existing_names = set([ + node.name for node in NB_API.dcim.devices.filter(name=node_names) + ]) + missing_nodes = list(node_names - existing_names) + if len(missing_nodes) > 0: + missing = [f'Node "{node_name}" is missing!' for node_name in missing_nodes] + raise ValidationError('Not all cluster nodes registered in NetBox!', missing) + + +def _assert_cluster_does_not_exist(proxmox_cluster): + cluster_name = proxmox_cluster['name'] + netbox_cluster_list = NB_API.virtualization.clusters.filter(name=cluster_name) + if len(netbox_cluster_list) != 0: + raise ValidationError(f'Virtualization cluster "{cluster_name}" already exists!') + + +def cluster(proxmox_cluster, proxmox_nodes): + try: + # Already formatted the data for this :) + netbox_cluster = NB_API.virtualization.clusters.create(**proxmox_cluster) + except Exception as e: + raise APIError(e) + # Update nodes' cluster + node_names = [node['name'] for node in proxmox_nodes] + netbox_nodes = list(NB_API.dcim.devices.filter(name=[node_names])) + for node in netbox_nodes: + node.cluster = {'name': proxmox_cluster['name']} + if not NB_API.dcim.devices.update(netbox_nodes): + # In case of error "rollback" + netbox_cluster.delete() + raise APIError('Failed to set Nodes\' cluster!') + # Return JSON serializable dict :) + return dict(netbox_cluster) + + +def tags(): + proxmox_tags = Proxmox.fetch_tags() + try: + existing_tags = NB_API.extras.tags.filter(content_types=[ + 'virtualization.virtualmachines', + ]) + except Exception as e: + raise APIError(e) + # Tags for VMs currently should only be managed by this plugin + # TODO: we could add a custom field to mark tags managed by the plugin, for instance + if len(existing_tags) > 0: + names = [tag.name for tag in existing_tags] + errors = [f'Tag "{name}" should not exist!' for name in names] + raise ValidationError('Some VM tags already exist!', errors) + try: + netbox_tags = NB_API.extras.tags.create(proxmox_tags) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(tag) for tag in netbox_tags] + + +def custom_fields(): + try: + vmid_already_exists = len(NB_API.extras.custom_fields.filter( + name='vmid', object_types='virtualization.virtualmachine' + )) > 0 + except Exception as e: + raise APIError(e) + if vmid_already_exists: + raise ValidationError('Custom field "vmid" already exists!') + custom_fields = [{ + 'name': 'vmid', + 'label': 'VMID', + 'description': '[Proxmox] VM/CT ID', + 'ui_editable': 'no', + 'ui_visible': 'always', + 'filter_logic': 'exact', + 'type': 'integer', + 'object_types': ['virtualization.virtualmachine'] + }] + # Create stuff :) + try: + netbox_custom_fields = NB_API.extras.custom_fields.create(custom_fields) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(custom_field) for custom_field in netbox_custom_fields] + + +def vms_and_interfaces(): + proxmox_vms, proxmox_interfaces = Proxmox.fetch_virtual_machines_and_interfaces() + try: + netbox_vms = NB_API.virtualization.virtual_machines.create(proxmox_vms) + netbox_interfaces = NB_API.virtualization.interfaces.create(proxmox_interfaces) + except Exception as e: + raise APIError(e) + # Return JSON serializable lists :) + return [dict(x) for x in netbox_vms], [dict(x) for x in netbox_interfaces] + + +def all(): + proxmox_cluster, proxmox_nodes = Proxmox.fetch_cluster_and_nodes() + _assert_nodes_exist(proxmox_nodes) + _assert_cluster_does_not_exist(proxmox_cluster) + # Leave cluster out first so the first error comes quickly, useful for API + # errors like invalid token for instance, which might be common. + # (netbox takes tooooo long to delete <stuff>, even if <stuff> does not exist) + netbox_cluster = cluster(proxmox_cluster, proxmox_nodes) + # In case the cluster() call succeeds we are sure that we have write access + # for the API, so any error that comes out of the following calls must be + # something else (or you messed up things). + # This is useful because we can be pretty sure that a "rollback" is possible + # (the delete_everything() call below *should* NOT raise an exception). + try: + netbox_tags = tags() + netbox_custom_fields = custom_fields() + netbox_vms, netbox_interfaces = vms_and_interfaces() + except Exception as e: + # "rollback" in case of any failure + # (if this function fails you probably messed up in a very interesting way) + delete_everything() + raise e + return { + 'cluster': netbox_cluster, + 'tags': netbox_tags, + 'custom_fields': netbox_custom_fields, + 'vms': netbox_vms, + 'interfaces': netbox_interfaces, + } diff --git a/netbox_proxmox_sync/api/netbox/delete.py b/netbox_proxmox_sync/api/netbox/delete.py new file mode 100644 index 0000000000000000000000000000000000000000..c01a199a2401b322f5af8c0ce9173aa9d441b1fe --- /dev/null +++ b/netbox_proxmox_sync/api/netbox/delete.py @@ -0,0 +1,91 @@ +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox + + +def cluster(proxmox_cluster): + cluster_name = proxmox_cluster['name'] + try: + netbox_clusters = NB_API.virtualization.clusters.filter(name=cluster_name) + except Exception as e: + raise APIError(e) + if (len(netbox_clusters)) == 0: + raise ValidationError(f'Cluster "{cluster_name}" does not exist!') + netbox_cluster = list(netbox_clusters)[0] + # Return JSON serializable dict :) + return dict(netbox_cluster) + + +def tags(): + try: + netbox_tags = NB_API.extras.tags.filter(content_types=[ + 'virtualization.virtualmachines', + ]) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(tag) for tag in netbox_tags] + + +def custom_fields(): + try: + netbox_custom_fields = NB_API.extras.custom_fields.filter( + name='vmid', + object_types='virtualization.virtualmachine', + ) + except Exception as e: + raise APIError(e) + # Return JSON serializable list :) + return [dict(custom_field) for custom_field in netbox_custom_fields] + + +def vms_and_interfaces(netbox_cluster): + try: + cluster_id = netbox_cluster['id'] + netbox_vms = NB_API.virtualization.virtual_machines.filter(cluster_id=cluster_id) + netbox_interfaces = NB_API.virtualization.interfaces.filter(cluster_id=cluster_id) + except Exception as e: + raise APIError(e) + # Return JSON serializable lists :) + return [dict(x) for x in netbox_vms], [dict(x) for x in netbox_interfaces] + + +def delete_stuff(cluster, tags, custom_fields, vms, interfaces): + # This order is important + rt = NB_API.extras.tags.delete([tag['id'] for tag in tags]) + rc = NB_API.extras.custom_fields.delete([field['id'] for field in custom_fields]) + ri = NB_API.virtualization.interfaces.delete([i['id'] for i in interfaces]) + rv = NB_API.virtualization.virtual_machines.delete([vm['id'] for vm in vms]) + rf = NB_API.virtualization.clusters.delete([cluster['id']]) + errors = [] + if rt is None: + errors.append('Could not delete tags!') + if rf is None: + errors.append('Could not delete custom_fields!') + if rc is None: + errors.append('Could not delete cluster!') + if rv is None: + errors.append('Could not delete virtual machines!') + if ri is None: + errors.append('Could not delete interfaces!') + if len(errors) > 0: + raise APIError('Could not delete plugin information!', errors=errors) + + +def all(): + proxmox_cluster, _ = Proxmox.fetch_cluster_and_nodes() + netbox_cluster = cluster(proxmox_cluster) + netbox_tags = tags() + netbox_custom_fields = custom_fields() + netbox_vms, netbox_interfaces = vms_and_interfaces(netbox_cluster) + delete_stuff( + netbox_cluster, netbox_tags, netbox_custom_fields, + netbox_vms, netbox_interfaces + ) + return { + 'cluster': netbox_cluster, + 'tags': netbox_tags, + 'custom_fields': netbox_custom_fields, + 'vms': netbox_vms, + 'interfaces': netbox_interfaces, + } diff --git a/netbox_proxmox_sync/api/netbox/extract.py b/netbox_proxmox_sync/api/netbox/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..077b70391c5f8052fbcb8a1b3d329ba2da722ec2 --- /dev/null +++ b/netbox_proxmox_sync/api/netbox/extract.py @@ -0,0 +1,55 @@ +from netbox_proxmox_sync.api.config import ( + NB_API, + NETBOX_SITE_ID, + NETBOX_CLUSTER_TYPE_ID, +) +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.utils.models import ( + tag_from_netbox, + vm_from_netbox, + interface_from_netbox, +) + + +class NetBox: + @staticmethod + def fetch_cluster(cluster_name): + try: + netbox_cluster = NB_API.virtualization.clusters.filter(name=cluster_name) + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + return { + 'id': netbox_cluster.id, + 'name': netbox_cluster.name, + 'type': NETBOX_CLUSTER_TYPE_ID, + 'description': netbox_cluster.description, + 'site': NETBOX_SITE_ID, + } + + @staticmethod + def fetch_tags(): + try: + netbox_tags = [dict(tag) for tag in NB_API.extras.tags.filter( + content_types='virtualization.virtualmachines', + )] + except Exception as e: + raise APIError(e) + + return [tag_from_netbox(tag) for tag in netbox_tags] + + @staticmethod + def fetch_virtual_machines_and_interfaces(cluster_id): + try: + netbox_vms = [ + dict(vm) for vm in NB_API.virtualization.virtual_machines.filter( + cluster_id=cluster_id + ) + ] + netbox_interfaces = [dict(i) for i in NB_API.virtualization.interfaces.all()] + except Exception as e: + raise APIError(e) + + vms = [vm_from_netbox(vm) for vm in netbox_vms] + interfaces = [interface_from_netbox(i) for i in netbox_interfaces] + + return vms, interfaces diff --git a/netbox_proxmox_sync/api/netbox/update.py b/netbox_proxmox_sync/api/netbox/update.py new file mode 100644 index 0000000000000000000000000000000000000000..368105da90e4222fd8f100273682e689f1e64639 --- /dev/null +++ b/netbox_proxmox_sync/api/netbox/update.py @@ -0,0 +1,123 @@ +from netbox_proxmox_sync.api.config import NB_API +from netbox_proxmox_sync.api.utils.errors import APIError, ValidationError +from netbox_proxmox_sync.api.proxmox.extract import Proxmox +from netbox_proxmox_sync.api.netbox.extract import NetBox + + +# MERGE, get created_*, updated_*, deleted_* +def tags(): + proxmox_tags = Proxmox.fetch_tags() + netbox_tags = NetBox.fetch_tags() + px_tags = {tag['name']: tag for tag in proxmox_tags} + nb_tags = {tag['name']: tag for tag in netbox_tags} + # "merge" information, extract stuff to C/U/D + created = [tag for tag in proxmox_tags if tag['name'] not in nb_tags] + deleted = [tag for tag in netbox_tags if tag['name'] not in px_tags] + updated = [] + for name in px_tags: + # only thing that can really change for tags is their color + if name in nb_tags and px_tags[name]['color'] != nb_tags[name]['color']: + nb_tags[name]['color'] = px_tags[name]['color'] + updated.append(nb_tags[name]) + # FIXME: error handling + NB_API.extras.tags.create(created) + NB_API.extras.tags.update(updated) + NB_API.extras.tags.delete([d['id'] for d in deleted]) + return created, updated, deleted + + +# TODO: allow for updates of custom fields in case the plugin is updated to use more +# def custom_fields(): + + +def vms(proxmox_vms, netbox_vms): + px_vms = {vm['custom_fields']['vmid']: vm for vm in proxmox_vms} + nb_vms = {vm['custom_fields']['vmid']: vm for vm in netbox_vms} + # "merge" information, extract stuff to C/U/D + created = [vm for vm in proxmox_vms if vm['custom_fields']['vmid'] not in nb_vms] + deleted = [vm for vm in netbox_vms if vm['custom_fields']['vmid'] not in px_vms] + updated = [] + for name in px_vms: + changed = False + px_vm = px_vms[name] + if nb_vms.get(name) is None: + continue + nb_vm = nb_vms[name] + # Update all fields + for field in px_vm: + changed |= nb_vm[field] != px_vm[field] + nb_vm[field] = px_vm[field] + if changed: + updated.append(nb_vm) + return created, updated, deleted + + +def interfaces(proxmox_interfaces, netbox_interfaces): + # Note: if the MAC changes the IP for the VM is lost :) + px_interfaces = {i['mac_address']: i for i in proxmox_interfaces} + nb_interfaces = {i['mac_address']: i for i in netbox_interfaces} + created = [i for i in proxmox_interfaces if i['mac_address'] not in nb_interfaces] + deleted = [i for i in netbox_interfaces if i['mac_address'] not in px_interfaces] + updated = [] + for name in px_interfaces: + changed = False + # Update all fields + px_interface = px_interfaces[name] + if nb_interfaces.get(name) is None: + continue + nb_interface = nb_interfaces[name] + for field in px_interface: + changed |= nb_interface[field] != px_interface[field] + nb_interface[field] = px_interface[field] + if changed: + updated.append(nb_interface) + return created, updated, deleted + + +def vms_and_interfaces(cluster_id): + proxmox_vms, proxmox_interfaces = Proxmox.fetch_virtual_machines_and_interfaces() + netbox_vms, netbox_interfaces = NetBox.fetch_virtual_machines_and_interfaces(cluster_id) + + created_vms, updated_vms, deleted_vms = vms(proxmox_vms, netbox_vms) + created_i, updated_i, deleted_i = interfaces(proxmox_interfaces, netbox_interfaces) + + # FIXME: error handling + NB_API.virtualization.virtual_machines.create(created_vms) + NB_API.virtualization.virtual_machines.update(updated_vms) + NB_API.virtualization.virtual_machines.delete([d['id'] for d in deleted_vms]) + NB_API.virtualization.interfaces.create(created_i) + NB_API.virtualization.interfaces.update(updated_i) + # NB_API.virtualization.interfaces.delete([d['id'] for d in deleted]) + + return created_vms, updated_vms, deleted_vms, \ + created_i, updated_i, deleted_i + + +def all(): + proxmox_cluster = Proxmox.fetch_cluster() + cluster_name = proxmox_cluster['name'] + clusters = list(NB_API.virtualization.clusters.filter(name=cluster_name)) + if (len(clusters)) == 0: + raise ValidationError(f'Cluster "{cluster_name}" does not exist!') + netbox_cluster = clusters[0] + created_tags, updated_tags, deleted_tags = tags() + created_vms, updated_vms, deleted_vms, \ + created_interfaces, updated_interfaces, deleted_interfaces = \ + vms_and_interfaces(cluster_id=netbox_cluster.id) + return { + 'tags': { + 'created': created_tags, + 'updated': updated_tags, + 'deleted': deleted_tags, + }, + 'virtual_machines': { + 'created': created_vms, + 'updated': updated_vms, + 'deleted': deleted_vms, + }, + 'interfaces': { + 'created': created_interfaces, + 'updated': updated_interfaces, + 'deleted': deleted_interfaces, + } + } diff --git a/netbox_proxmox_sync/api/proxmox/create.py b/netbox_proxmox_sync/api/proxmox/create.py deleted file mode 100644 index d14b3b4bc93eb3786dde4ed4134fc847f39376a5..0000000000000000000000000000000000000000 --- a/netbox_proxmox_sync/api/proxmox/create.py +++ /dev/null @@ -1,109 +0,0 @@ -from netbox_proxmox_sync.api.errors import APIError, ValidationError -from .extract import fetch_cluster_and_nodes, fetch_tags, fetch_virtual_machines_and_interfaces -from .config import NETBOX_API - - -def assert_cluster_does_not_exist(cluster_name): - netbox_cluster_list = NETBOX_API.virtualization.clusters.filter(name=cluster_name) - if len(netbox_cluster_list) != 0: - raise ValidationError(f'Virtualization cluster "{cluster_name}" already exists!') - - -def assert_nodes_exist(proxmox_nodes): - node_names = set([node['name'] for node in proxmox_nodes]) - existing_names = set([ - node.name for node in NETBOX_API.dcim.devices.filter(name=node_names) - ]) - missing_nodes = list(node_names - existing_names) - if len(missing_nodes) > 0: - missing = [f'Node "{node_name}" is missing!' for node_name in missing_nodes] - raise ValidationError('Not all cluster nodes registered in NetBox!', missing) - - -def create_cluster(proxmox_cluster, proxmox_nodes): - try: - # Already formatted the data for this :) - netbox_cluster = NETBOX_API.virtualization.clusters.create(**proxmox_cluster) - node_names = [node['name'] for node in proxmox_nodes] - # Update nodes' cluster - netbox_nodes = list(NETBOX_API.dcim.devices.filter(name=[node_names])) - for node in netbox_nodes: - node.cluster = {'name': proxmox_cluster['name']} - # In case of error "rollback" - if not NETBOX_API.dcim.devices.update(netbox_nodes): - netbox_cluster.delete() - raise APIError('Failed to set Nodes\' cluster!') - except Exception as e: - raise APIError(e) - # Return JSON serializable dict :) - return dict(netbox_cluster) - - -def create_tags_and_custom_fields(proxmox_tags): - try: - existing_tags = NETBOX_API.extras.tags.filter(content_types=[ - 'virtualization.virtualmachines', - ]) - except Exception as e: - raise APIError(e) - # If tags for VMs already exist complain - if len(existing_tags) > 0: - names = [tag.name for tag in existing_tags] - errors = [f'Tag "{name}" should not exist!' for name in names] - raise ValidationError('Some VM tags already exist!', errors) - # If vmid custom field already exist complain - custom_field_exists = len(NETBOX_API.extras.custom_fields.filter(name='vmid')) > 0 - if custom_field_exists: - raise ValidationError('Custom field "vmid" already exists!') - custom_fields = [{ - 'name': 'vmid', - 'label': 'VMID', - 'description': '[Proxmox] VM/CT ID', - 'ui_editable': 'no', - 'ui_visible': 'always', - 'filter_logic': 'exact', - 'type': 'integer', - 'object_types': ['virtualization.virtualmachine'] - }] - # Create stuff :) - try: - netbox_tags = NETBOX_API.extras.tags.create(proxmox_tags) - netbox_custom_fields = NETBOX_API.extras.custom_fields.create(custom_fields) - except Exception as e: - raise APIError(e) - # Return JSON serializable lists :) - return [dict(x) for x in netbox_tags], [dict(x) for x in netbox_custom_fields] - - -def create_virtual_machines(proxmox_vms, proxmox_interfaces): - # Just create stuff :) - try: - netbox_vms = NETBOX_API.virtualization.virtual_machines.create(proxmox_vms) - netbox_interfaces = NETBOX_API.virtualization.interfaces.create(proxmox_interfaces) - except Exception as e: - raise APIError(e) - # Return JSON serializable lists :) - return [dict(x) for x in netbox_vms], [dict(x) for x in netbox_interfaces] - - -# FIXME: should try a full rollback in case of any error -def all(): - cluster, nodes = fetch_cluster_and_nodes() - tags = fetch_tags() - vms, interfaces = fetch_virtual_machines_and_interfaces() - # Assert some stuff first - assert_cluster_does_not_exist(cluster['name']) - assert_nodes_exist(nodes) - # Create basic info - netbox_cluster = create_cluster(cluster, nodes) - netbox_tags, netbox_custom_fields = create_tags_and_custom_fields(tags) - # Do the first "sync" (creates VMs and their respective interfaces) - netbox_vms, netbox_interfaces = create_virtual_machines(vms, interfaces) - return { - 'cluster': netbox_cluster, - 'tags': netbox_tags, - 'custom_fields': netbox_custom_fields, - # 'nodes': netbox_nodes, - 'virtual_machines': netbox_vms, - 'interfaces': netbox_interfaces - } diff --git a/netbox_proxmox_sync/api/proxmox/delete.py b/netbox_proxmox_sync/api/proxmox/delete.py deleted file mode 100644 index fca5c60bdc0100292f631cf7805008f493bd5734..0000000000000000000000000000000000000000 --- a/netbox_proxmox_sync/api/proxmox/delete.py +++ /dev/null @@ -1,52 +0,0 @@ -from netbox_proxmox_sync.api.errors import APIError, ValidationError -from .extract import fetch_cluster_and_nodes -from .config import NETBOX_API - - -def all(): - # Fetch all data (for proper output) - cluster, _ = fetch_cluster_and_nodes() - netbox_clusters = list(NETBOX_API.virtualization.clusters.filter( - name=cluster['name'] - )) - if len(netbox_clusters) == 0: - raise ValidationError(f'Cluster "{cluster["name"]}" does not exist!') - netbox_cluster = dict(netbox_clusters[0]) - try: - netbox_interfaces = [dict(i) for i in NETBOX_API.virtualization.interfaces.all()] - netbox_vms = [dict(i) for i in NETBOX_API.virtualization.virtual_machines.filter( - cluster_id=netbox_cluster['id'], - )] - netbox_tags = [dict(f) for f in NETBOX_API.extras.tags.filter( - content_types=['virtualization.virtualmachines'], - )] - netbox_custom_fields = [dict(f) for f in NETBOX_API.extras.custom_fields.filter( - object_types=['virtualization.virtualmachine'], - )] - except Exception: - raise APIError(f'Failed to fetch all "{cluster["name"]}" cluster information!') - # Actually delete stuff - si = NETBOX_API.virtualization.interfaces.delete([ - interface['id'] for interface in netbox_interfaces - ]) - sv = NETBOX_API.virtualization.virtual_machines.delete([ - vm['id'] for vm in netbox_vms - ]) - st = NETBOX_API.extras.tags.delete([ - tag['id'] for tag in netbox_tags - ]) - sf = NETBOX_API.extras.custom_fields.delete([ - custom_field['id'] for custom_field in netbox_custom_fields - ]) - sc = NETBOX_API.virtualization.clusters.delete([netbox_cluster['id']]) - if not (si and sv and st and sf and sc): - raise APIError(f'Failed to delete all "{cluster["name"]}" cluster information!') - - return { - 'cluster': netbox_cluster, - 'tags': netbox_tags, - 'custom_fields': netbox_custom_fields, - # 'nodes': netbox_nodes, - 'virtual_machines': netbox_vms, - 'interfaces': netbox_interfaces - } diff --git a/netbox_proxmox_sync/api/proxmox/extract.py b/netbox_proxmox_sync/api/proxmox/extract.py index 203bab2b60723b5e0312a0637b64d37ad4110717..34590706509b564cc57125f92c70cf54f4b26c4a 100644 --- a/netbox_proxmox_sync/api/proxmox/extract.py +++ b/netbox_proxmox_sync/api/proxmox/extract.py @@ -1,132 +1,119 @@ -from netbox_proxmox_sync.api.errors import APIError -from ..proxmox.config import ( - PROXMOX_API, +from netbox_proxmox_sync.api.config import ( + PX_API, NETBOX_SITE_ID, NETBOX_CLUSTER_DESCRIPTION, NETBOX_CLUSTER_TYPE_ID, - NETBOX_DEFAULT_TAG_COLOR, +) +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.utils.models import ( + tag_from_proxmox, + vm_from_proxmox, + interface_from_proxmox, ) -def fetch_cluster_and_nodes(): - try: - proxmox_cluster_info = PROXMOX_API.cluster.status.get() - except Exception: - raise APIError('Failed to connect to Proxmox Cluster!') - cluster = { - 'name': proxmox_cluster_info[0]['name'], - 'type': NETBOX_CLUSTER_TYPE_ID, - 'description': NETBOX_CLUSTER_DESCRIPTION, - 'site': NETBOX_SITE_ID, - } - return cluster, proxmox_cluster_info[1:] - - -def fetch_tags(): - try: - proxmox_tag_info = PROXMOX_API.cluster.options.get() - except Exception: - raise APIError('Failed to connect to Proxmox Cluster!') - allowed_tags = proxmox_tag_info['allowed-tags'] - tag_colormap = proxmox_tag_info['tag-style']['color-map'].split(';') - tags = [] - # Create tags with no color defined - for tag_name in allowed_tags: - tag_slug = tag_name.lower().replace(' ', '-').replace('.', '_') - tag_color = NETBOX_DEFAULT_TAG_COLOR.lower() - tags.append({ - 'name': tag_name, - 'slug': tag_slug, - 'color': tag_color, - 'object_types': ['virtualization.virtualmachine'] - }) - # Remap defined tag colors - for tag_info in tag_colormap: - tag_name = tag_info.split(':')[0] - tag_slug = tag_name.lower().replace(' ', '-').replace('.', '_') - tag_color = tag_info.split(':')[1].lower() - found = False - # Find existing tag and update its color - for tag in tags: - if tag['name'] == tag_name: - found = True - tag['color'] = tag_color - break - # ??? - if not found: - tags.append({ - 'name': tag_name, - 'slug': tag_slug, - 'color': tag_color, - 'object_types': ['virtualization.virtualmachine'] - }) - return tags - - -def fetch_virtual_machines_and_interfaces(): - try: - cluster_status = PROXMOX_API.cluster.status.get() - except Exception: - raise APIError('Failed to connect to Proxmox Cluster!') - cluster_name = cluster_status[0]['name'] - proxmox_nodes = [node['name'] for node in cluster_status[1:]] - # List, because node information is stored in the VMs themselves - virtual_machines = [] - interfaces = [] - for node_name in proxmox_nodes: - try: - node_vms = PROXMOX_API.nodes(node_name).qemu.get() - except Exception: - raise APIError('Failed to connect to Proxmox Cluster!') - for vm_status in node_vms: - new_vm, new_interface = extract_vm_data(cluster_name, node_name, vm_status) - virtual_machines.append(new_vm) - interfaces.extend(new_interface) - return virtual_machines, interfaces - - +# Utils for proper data extraction def extract_vm_data(cluster_name, node_name, vm_status): try: - vm_config = PROXMOX_API.nodes(node_name).qemu(vm_status['vmid']).config.get() + vm_config = PX_API.nodes(node_name).qemu(vm_status['vmid']).config.get() except Exception: raise APIError('Failed to connect to Proxmox Cluster!') - # Get full VM info for creation - # (specially "maxdisk" is unreliable in the original API call) - # plus we need the VM's interfaces - vm_data = { - 'name': vm_status['name'], - 'status': 'active' if vm_status['status'] == 'running' else 'offline', - 'device': {'name': node_name}, - 'cluster': {'name': cluster_name}, - 'vcpus': vm_status['cpus'], - 'memory': vm_status['maxmem'] / 2**20, - 'tags': [{'name': tag} for tag in vm_config['tags'].split(';')], - 'custom_fields': {'vmid': vm_status['vmid']} - } + tags = vm_config['tags'].split(';') + vm_data = vm_from_proxmox(cluster_name, node_name, vm_status, tags) interfaces_data = extract_vm_interfaces(vm_config) - # Some VMs either have no disk or their disk isn't explicitly set as the bootdisk - if vm_status.get('maxdisk') is not None and vm_status['maxdisk'] > 0: - vm_data['disk'] = int(vm_status['maxdisk'] / 2**30) - if vm_config.get('description') is not None: - # NetBox only allows 200 char description, but our VMs have more - # so we store the description in the "comments" instead - vm_data['comments'] = vm_config['description'] + return vm_data, interfaces_data def extract_vm_interfaces(proxmox_vm_config): + vm_name = proxmox_vm_config['name'] interfaces = [] for interface_name in [key for key in proxmox_vm_config if key.startswith('net')]: interface_info = proxmox_vm_config[interface_name] - # net[0-9]+: 'virtio=00:00:00:00:00:00,bridge=vmbr<VID>' - mac = interface_info.split('virtio=')[1].split(',')[0] - vlan_id = int(interface_info.split('bridge=vmbr')[1].split(',firewall')[0]) - interfaces.append({ - 'name': interface_name, - 'virtual_machine': {'name': proxmox_vm_config['name']}, - 'mac_address': mac.upper(), # NetBox saves uppercase anyways - 'mode': 'access', - 'untagged_vlan': {'vid': vlan_id}, - # 'bridge': bridge - }) + new_interface = interface_from_proxmox(vm_name, interface_name, interface_info) + interfaces.append(new_interface) return interfaces + + +class Proxmox: + @staticmethod + def fetch_cluster(): + cluster, _ = Proxmox.fetch_cluster_and_nodes() + return cluster + + @staticmethod + def fetch_cluster_and_nodes(): + try: + proxmox_cluster_info = PX_API.cluster.status.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + cluster = { + 'name': proxmox_cluster_info[0]['name'], + 'type': NETBOX_CLUSTER_TYPE_ID, + 'description': NETBOX_CLUSTER_DESCRIPTION, + 'site': NETBOX_SITE_ID, + } + return cluster, proxmox_cluster_info[1:] + + @staticmethod + def fetch_tags(): + try: + proxmox_tag_info = PX_API.cluster.options.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + allowed_tags = proxmox_tag_info['allowed-tags'] + tag_colormap = proxmox_tag_info['tag-style']['color-map'].split(';') + tags = [] + # Create tags with no color defined + for tag_name in allowed_tags: + tags.append(tag_from_proxmox(tag_name)) + # Remap defined tag colors + for tag_info in tag_colormap: + tag_name = tag_info.split(':')[0] + tag_slug = tag_name.lower().replace(' ', '-').replace('.', '_') + tag_color = tag_info.split(':')[1].lower() + found = False + # Find existing tag and update its color + for tag in tags: + if tag['name'] == tag_name: + found = True + tag['color'] = tag_color + break + # ??? + if not found: + tags.append({ + 'name': tag_name, + 'slug': tag_slug, + 'color': tag_color, + 'object_types': ['virtualization.virtualmachine'] + }) + return tags + + @staticmethod + def fetch_virtual_machines_and_interfaces(): + try: + cluster_status = PX_API.cluster.status.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + cluster_name = cluster_status[0]['name'] + proxmox_nodes = [node['name'] for node in cluster_status[1:]] + # List, because node information is stored in the VMs themselves + # (same for interfaces: vm info is stored in the interface itself) + virtual_machines = [] + interfaces = [] + for node_name in proxmox_nodes: + try: + node_vms = PX_API.nodes(node_name).qemu.get() + except Exception: + raise APIError('Failed to connect to Proxmox Cluster!') + for vm_status in node_vms: + # If the update runs as the VM is being cloned the vm_config will be + # messed up and our update will fail. + if vm_status.get('lock') is not None and vm_status['lock'] == 'clone': + continue + new_vm, new_interfaces = extract_vm_data( + cluster_name, node_name, vm_status + ) + virtual_machines.append(new_vm) + interfaces.extend(new_interfaces) + return virtual_machines, interfaces diff --git a/netbox_proxmox_sync/api/proxmox/update.py b/netbox_proxmox_sync/api/proxmox/update.py deleted file mode 100644 index 007bafa3046c418768c462fe04d1ded904357529..0000000000000000000000000000000000000000 --- a/netbox_proxmox_sync/api/proxmox/update.py +++ /dev/null @@ -1,241 +0,0 @@ -from netbox_proxmox_sync.api.errors import APIError, ValidationError -from .extract import fetch_cluster_and_nodes, fetch_tags, fetch_virtual_machines_and_interfaces -from .config import NETBOX_API - - -def assert_cluster_and_nodes_exist(proxmox_cluster, proxmox_nodes): - cluster_name = proxmox_cluster['name'] - netbox_cluster_list = NETBOX_API.virtualization.clusters.filter(name=cluster_name) - if len(netbox_cluster_list) != 1: - raise ValidationError( - f'A single "{cluster_name}" virtualization cluster should exist!' - ) - node_names = set([node['name'] for node in proxmox_nodes]) - existing_names = set([ - node.name for node in NETBOX_API.dcim.devices.filter(name=node_names) - ]) - missing_nodes = list(node_names - existing_names) - if len(missing_nodes) > 0: - missing = [f'Node "{node_name}" is missing!' for node_name in missing_nodes] - raise ValidationError('Not all cluster nodes registered in NetBox!', missing) - return list(netbox_cluster_list)[0].id - - -def update_tags(proxmox_tags): - try: - netbox_tags = [dict(tag) for tag in NETBOX_API.extras.tags.filter( - content_types=['virtualization.virtualmachines'], - )] - except Exception: - raise APIError('Failed to connect to NetBox Cluster!') - new_tags = {tag['name']: tag for tag in proxmox_tags} - old_tags = {tag['name']: tag for tag in netbox_tags} - - deleted = [tag for tag in netbox_tags if tag['name'] not in new_tags] - to_create = [tag for tag in proxmox_tags if tag['name'] not in old_tags] - updated = [] - for name in new_tags: - # if it already exists in netbox but something changed: it's updated - # (only thing that can really change is the color) - if name in old_tags and new_tags[name]['color'] != old_tags[name]['color']: - # Better to update the new dict - old_tags[name]['color'] = new_tags[name]['color'] - updated.append(old_tags[name]) - try: - created = [dict(x) for x in NETBOX_API.extras.tags.create(to_create)] - except Exception: - raise APIError('Error updating tags!') - deleted_ids = [tag['id'] for tag in deleted] - result_update = len(updated) == 0 or NETBOX_API.extras.tags.update(updated) - result_delete = len(deleted) == 0 or NETBOX_API.extras.tags.delete(deleted_ids) - if not result_update or not result_delete: - raise APIError('Error updating tags!') - - return created, updated, deleted - - -def update_old_vms(old_vms, new_vms): - updated = [] - for name in new_vms: - # if it already exists in netbox but something changed: we need to update it - if name not in old_vms: - continue - something_changed = False - # Without this the update is messed, because it's a dict, same for all the "del"s - # below these ifs (long story, just believe me here) - del old_vms[name]['site'] - if old_vms[name].get('primary_ip4') is not None: - ip = old_vms[name]['primary_ip4']['address'] - old_vms[name]['primary_ip4'] = {'address': ip} - if old_vms[name].get('primary_ip') is not None: - ip = old_vms[name]['primary_ip']['address'] - old_vms[name]['primary_ip'] = {'address': ip} - if old_vms[name].get('primary_ip6') is not None: - ip = old_vms[name]['primary_ip6']['address'] - old_vms[name]['primary_ip6'] = {'address': ip} - # Iterate over properties, set new stuff - for key in new_vms[name]: - # Edge cases... 4 of them... - if key == 'maxdisk' or key == 'description': - # These two keys may be None sometimes, which is annoying - # If it's None now, delete key - if new_vms[name].get(key) is None and old_vms[name].get(key) is not None: - del old_vms[name][key] - something_changed = True - # If it was None and isn't anymore, set key - if new_vms[name].get(key) is not None and old_vms[name].get(key) is None: - old_vms[name][key] = new_vms[name][key] - something_changed = True - elif key == 'status': - # Netbox returns status via 'value' key... (WHY????) - if old_vms[name][key]['value'] != new_vms[name][key]: - old_vms[name][key] = new_vms[name][key] - something_changed = True - else: - del old_vms[name][key] - elif key == 'device' or key == 'cluster': - # Device & cluster are set by name - # (i don't think I made a cluster name change possible... but whatever) - if old_vms[name][key]['name'] != new_vms[name][key]['name']: - old_vms[name][key] = new_vms[name][key] - something_changed = True - else: - del old_vms[name][key] - elif key == 'tags': - # Tags is tecnically a list, so we update the whole thing - old_tags = set([tag['name'] for tag in old_vms[name][key]]) - new_tags = set([tag['name'] for tag in new_vms[name][key]]) - # Reattribute tags - old_vms[name][key] = new_vms[name][key] - if old_tags != new_tags: - something_changed = True - # General case: value for key is different means it changed - elif old_vms[name][key] != new_vms[name][key]: - old_vms[name][key] = new_vms[name][key] - something_changed = True - if something_changed: - updated.append(old_vms[name]) - return updated - - -def update_old_interfaces(proxmox_interfaces, netbox_interfaces): - created = [] - updated = [] - deleted = [] - # check something changed: create/update/delete interface - # Note: interfaces is a list for each VM - # for each proxmox_interface not in deleted, search for it in netbox - for pi in proxmox_interfaces: - found = False - # if found: update/do nothing - for ni in netbox_interfaces: - updated_interface = False - # same VM, same interface name, see if it's necessary to update - if ni.virtual_machine['name'] == pi['virtual_machine']['name']: - if ni.name == pi['name']: - found = True - updated_interface = ni.untagged_vlan.vid != pi['untagged_vlan']['vid'] or \ - ni.mac_address.upper() != pi['mac_address'].upper() - ni.mac_address = pi['mac_address'].upper() - ni.untagged_vlan = pi['untagged_vlan'] - if updated_interface: - ui = dict(ni) - # Virtual machine won't change, nor mode - del ui['virtual_machine'] - del ui['mode'] - # better to be a dict here - updated.append(ui) - break - # if not found: create - if not found: - created.append(pi) - for ni in netbox_interfaces: - found = False - # if not found in proxmox: delete (seem simple?) - for pi in proxmox_interfaces: - if ni.virtual_machine['name'] == pi['virtual_machine']['name']: - if ni.name == pi['name']: - found = True - break - if not found: - deleted.append(dict(ni)) - return created, updated, deleted - - -def update_virtual_machines(cluster_id, proxmox_vms, proxmox_interfaces): - try: - netbox_vms = list(NETBOX_API.virtualization.virtual_machines.filter( - cluster_id=cluster_id - )) - netbox_interfaces = list(NETBOX_API.virtualization.interfaces.all()) - except Exception: - raise APIError('Failed to connect to NetBox Cluster!') - # TODO: redo this with vmid, which is more stable, name may change more - new_vms = {vm['name']: vm for vm in proxmox_vms} - old_vms = {vm.name: dict(vm) for vm in netbox_vms} - # Find out which ones to delete, which to create and which to update - deleted = [dict(vm) for vm in netbox_vms if vm.name not in new_vms] - to_create = [vm for vm in proxmox_vms if vm['name'] not in old_vms] - updated = update_old_vms(old_vms, new_vms) - - for interface in netbox_interfaces: - interface.virtual_machine = {'name': interface.virtual_machine['name']} - to_create_i, updated_i, deleted_i = update_old_interfaces( - proxmox_interfaces, netbox_interfaces - ) - - # First create - try: - created = [ - dict(v) for v in NETBOX_API.virtualization.virtual_machines.create(to_create) - ] - created_i = [ - dict(i) for i in NETBOX_API.virtualization.interfaces.create(to_create_i) - ] - except Exception: - raise APIError('Error creating VMs & interfaces!') - # Then delete (interfaces first) - deleted_ids = [vm['id'] for vm in deleted] - deleted_i_ids = [i['id'] for i in deleted_i] - result_delete_i = len(deleted_i) == 0 or NETBOX_API.virtualization.interfaces.delete(deleted_i_ids) - result_delete = len(deleted) == 0 or NETBOX_API.virtualization.virtual_machines.delete(deleted_ids) - if not result_delete or not result_delete_i: - raise APIError('Error deleting VMs & interfaces!') - # Then update (whatever order) - result_update = len(updated) == 0 or NETBOX_API.virtualization.virtual_machines.update(updated) - result_update_i = len(updated_i) == 0 or NETBOX_API.virtualization.interfaces.update(updated_i) - if not result_update or not result_update_i: - raise APIError('Error updating VMs & interfaces!') - return created, updated, deleted, created_i, updated_i, deleted_i - # return to_create, updated, deleted, to_create_i, updated_i, deleted_i - - -def all(): - cluster, nodes = fetch_cluster_and_nodes() - tags = fetch_tags() - vms, interfaces = fetch_virtual_machines_and_interfaces() - # Assert some stuff first - cluster_id = assert_cluster_and_nodes_exist(cluster, nodes) - # In case tags have changed - created_tags, updated_tags, deleted_tags = update_tags(tags) - # Do the first "sync" (creates VMs and their respective interfaces) - created_vms, updated_vms, deleted_vms, \ - created_interfaces, updated_interfaces, deleted_interfaces = \ - update_virtual_machines(cluster_id, vms, interfaces) - return { - 'tags': { - 'created': created_tags, - 'updated': updated_tags, - 'deleted': deleted_tags, - }, - 'virtual_machines': { - 'created': created_vms, - 'updated': updated_vms, - 'deleted': deleted_vms, - }, - 'interfaces': { - 'created': created_interfaces, - 'updated': updated_interfaces, - 'deleted': deleted_interfaces, - } - } diff --git a/netbox_proxmox_sync/api/utils/errors.py b/netbox_proxmox_sync/api/utils/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..9b16ad5edb3ade134a6a7d39aa5c876c4ed1e07d --- /dev/null +++ b/netbox_proxmox_sync/api/utils/errors.py @@ -0,0 +1,15 @@ +class APIError(Exception): + def __init__(self, message, status=500, errors=[]): + super().__init__(message) + self.status = status + self.errors = errors + + +class ValidationError(APIError): + def __init__(self, message, errors=[]): + super().__init__(message, 422, errors) + + +class UnauthorizedError(APIError): + def __init__(self, message): + super().__init__(message, 401) diff --git a/netbox_proxmox_sync/api/utils/models.py b/netbox_proxmox_sync/api/utils/models.py new file mode 100644 index 0000000000000000000000000000000000000000..cd974a3aaf0716c056911b5899562b666d33e9cd --- /dev/null +++ b/netbox_proxmox_sync/api/utils/models.py @@ -0,0 +1,167 @@ +from netbox_proxmox_sync.api.config import ( + NETBOX_DEFAULT_TAG_COLOR, + NETBOX_VM_ROLE_ID, +) +# Classes to ensure NetBox and Proxmox data have the exact same format and fields, +# which makes updating stuff correctly easier + + +class VirtualMachine: + def __init__(self, vmid, name, status, node_name, cluster_name, vcpus, maxmem, + role_id, tags=[], maxdisk=None, description=None, id=None): + self.vmid = vmid + self.name = name + self.status = status + self.node_name = node_name + self.cluster_name = cluster_name + self.role_id = role_id + self.vcpus = vcpus + self.maxmem = maxmem + self.tags = sorted(tags) + self.maxdisk = maxdisk + self.description = description + self.id = id + + def to_dict(self): + # Trust me, it's important that they're sorted + vm_data = { + 'name': self.name, + 'status': self.status, + 'device': {'name': self.node_name}, + 'cluster': {'name': self.cluster_name}, + 'vcpus': self.vcpus, + 'memory': self.maxmem, + 'role': self.role_id, + 'tags': [{'name': tag} for tag in self.tags], + 'custom_fields': {'vmid': self.vmid} + } + # Both these fields may be None, so we have to set them separately + if self.maxdisk is not None and self.maxdisk > 0: + vm_data['disk'] = int(self.maxdisk) + if self.description is not None: + # NetBox only allows 200 char description, but our VMs have more + # so we store the description in the "comments" instead + vm_data['comments'] = self.description + if self.id is not None: + vm_data['id'] = self.id + return vm_data + + +class VirtualInterface: + def __init__(self, name, vm_name, mac_address, vlan_id, id=None): + self.name = name + self.vm_name = vm_name + self.mac_address = mac_address + self.vlan_id = vlan_id + self.id = id + + def to_dict(self): + interface = { + 'name': self.name, + 'virtual_machine': {'name': self.vm_name}, + 'mac_address': self.mac_address, + 'mode': 'access', + 'untagged_vlan': {'vid': self.vlan_id}, + # 'bridge': bridge + } + if self.id is not None: + interface['id'] = self.id + return interface + + +class VirtualMachineTag: + def __init__(self, name, id=None, color=NETBOX_DEFAULT_TAG_COLOR): + self.name = name + self.slug = name.lower().replace(' ', '-').replace('.', '_') + self.color = color + self.id = id + + def to_dict(self): + vm_tag = { + 'name': self.name, + 'slug': self.slug, + 'color': self.color, + 'object_types': ['virtualization.virtualmachine'], + } + if self.id is not None: + vm_tag['id'] = self.id + return vm_tag + + +def tag_from_netbox(netbox_tag): + return VirtualMachineTag( + id=netbox_tag['id'], + name=netbox_tag['name'], + # slug=netbox_tag.slug, + color=netbox_tag['color'], + ).to_dict() + + +def vm_from_netbox(netbox_vm): + role_id = None + if netbox_vm.get('role'): + role_id = netbox_vm['role']['id'] + return VirtualMachine( + id=netbox_vm['id'], + vmid=netbox_vm['custom_fields']['vmid'], + name=netbox_vm['name'], + status=netbox_vm['status']['value'], + # FIXME: some of these are tricky + node_name=netbox_vm['device']['name'], + cluster_name=netbox_vm['cluster']['name'], + role_id=role_id, + vcpus=netbox_vm['vcpus'], + maxmem=netbox_vm['memory'], + tags=[tag['name'] for tag in netbox_vm['tags']], + maxdisk=netbox_vm.get('disk'), + description=netbox_vm.get('comments'), + ).to_dict() + + +def interface_from_netbox(netbox_interface): + return VirtualInterface( + id=netbox_interface['id'], + name=netbox_interface['name'], + vm_name=netbox_interface['virtual_machine']['name'], + mac_address=netbox_interface['mac_address'].upper(), + vlan_id=netbox_interface['untagged_vlan']['vid'], + ).to_dict() + + +def tag_from_proxmox(tag_name, color=NETBOX_DEFAULT_TAG_COLOR): + return VirtualMachineTag( + tag_name, + color, + ).to_dict() + + +def vm_from_proxmox(cluster_name, proxmox_node_name, proxmox_vm, tags=[]): + maxdisk = proxmox_vm.get('maxdisk') + if maxdisk is not None: + maxdisk = int(maxdisk) / 2 ** 30 # B -> GB + memory = int(proxmox_vm['maxmem']) / 2**20 # B -> MB + return VirtualMachine( + vmid=proxmox_vm['vmid'], + name=proxmox_vm['name'], + status='active' if proxmox_vm['status'] == 'running' else 'offline', + node_name=proxmox_node_name, + cluster_name=cluster_name, + vcpus=proxmox_vm['cpus'], + role_id=NETBOX_VM_ROLE_ID, + maxmem=memory, + tags=tags, + maxdisk=maxdisk, + description=proxmox_vm.get('description'), + ).to_dict() + + +def interface_from_proxmox(proxmox_vm_name, interface_name, proxmox_interface): + # net[0-9]+: 'virtio=00:00:00:00:00:00,bridge=vmbr<VID>' + mac = proxmox_interface.split('virtio=')[1].split(',')[0] + vlan_id = int(proxmox_interface.split('bridge=vmbr')[1].split(',firewall')[0]) + return VirtualInterface( + name=f'{proxmox_vm_name}:{interface_name}', + vm_name=proxmox_vm_name, + mac_address=mac.upper(), + vlan_id=vlan_id, + ).to_dict() diff --git a/netbox_proxmox_sync/api/views.py b/netbox_proxmox_sync/api/views.py index 57eff190973956f9b442aa6be70c6056921ea41d..67f07669dd8969fbe3ec7e8bdf8931c632eeb9ef 100644 --- a/netbox_proxmox_sync/api/views.py +++ b/netbox_proxmox_sync/api/views.py @@ -1,9 +1,10 @@ from django.http import HttpResponse from django.views import View from django.contrib.auth.mixins import PermissionRequiredMixin -from netbox_proxmox_sync.api.errors import APIError -from netbox_proxmox_sync.api.proxmox import create, update, delete +from netbox_proxmox_sync.api.utils.errors import APIError +from netbox_proxmox_sync.api.netbox import create, update, delete import json +import traceback # TODO: fix HTTP status codes # TODO: proper error handling @@ -17,7 +18,8 @@ class CreateCluster(PermissionRequiredMixin, View): json_result = json.dumps(result) return HttpResponse(json_result, content_type='application/json') except APIError as e: - json_result = json.dumps({'error': str(e)}) + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) return HttpResponse( json_result, status=e.status, content_type='application/json' ) @@ -32,7 +34,8 @@ class UpdateCluster(PermissionRequiredMixin, View): json_result = json.dumps(result) return HttpResponse(json_result, status=201, content_type='application/json') except APIError as e: - json_result = json.dumps({'error': str(e)}) + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) return HttpResponse( json_result, status=e.status, content_type='application/json' ) @@ -47,7 +50,8 @@ class DeleteCluster(PermissionRequiredMixin, View): json_result = json.dumps(result) return HttpResponse(json_result, status=201, content_type='application/json') except APIError as e: - json_result = json.dumps({'error': str(e)}) + raise e + json_result = json.dumps({'error': str(e), 'trace': traceback.print_exc()}) return HttpResponse( json_result, status=e.status, content_type='application/json' ) diff --git a/setup.py b/setup.py index d9b13f8e789e41f6d93b482fe148fbc9ebe12021..b59b584a527fb943a9e5275b27f0e474dc859826 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup setup( name='netbox-proxmox-sync', - version='1.0.2', + version='2.0.0', description='Import Proxmox cluster info into NetBox.', install_requires=['pynetbox', 'proxmoxer'], include_package_data=True,