├── .gitignore ├── .idea ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── netbox_vcenter.iml ├── LICENSE.txt ├── README.md ├── netbox_vcenter ├── __init__.py ├── background_tasks.py ├── forms.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── update_vcenter_cache.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20200403_0215.py │ └── __init__.py ├── models.py ├── navigation.py ├── template_content.py ├── templates │ └── netbox_vcenter │ │ ├── cluster │ │ └── vcenter_info.html │ │ └── virtualmachine │ │ ├── vcenter_refresh.html │ │ └── vcenter_resources.html ├── urls.py ├── validators.py └── views.py ├── pyproject.toml ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/artifacts 34 | # .idea/compiler.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Python template 75 | # Byte-compiled / optimized / DLL files 76 | __pycache__/ 77 | *.py[cod] 78 | *$py.class 79 | 80 | # C extensions 81 | *.so 82 | 83 | # Distribution / packaging 84 | .Python 85 | build/ 86 | develop-eggs/ 87 | dist/ 88 | downloads/ 89 | eggs/ 90 | .eggs/ 91 | lib/ 92 | lib64/ 93 | parts/ 94 | sdist/ 95 | var/ 96 | wheels/ 97 | pip-wheel-metadata/ 98 | share/python-wheels/ 99 | *.egg-info/ 100 | .installed.cfg 101 | *.egg 102 | MANIFEST 103 | 104 | # PyInstaller 105 | # Usually these files are written by a python script from a template 106 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 107 | *.manifest 108 | *.spec 109 | 110 | # Installer logs 111 | pip-log.txt 112 | pip-delete-this-directory.txt 113 | 114 | # Unit test / coverage reports 115 | htmlcov/ 116 | .tox/ 117 | .nox/ 118 | .coverage 119 | .coverage.* 120 | .cache 121 | nosetests.xml 122 | coverage.xml 123 | *.cover 124 | *.py,cover 125 | .hypothesis/ 126 | .pytest_cache/ 127 | 128 | # Translations 129 | *.mo 130 | *.pot 131 | 132 | # Django stuff: 133 | *.log 134 | local_settings.py 135 | db.sqlite3 136 | db.sqlite3-journal 137 | 138 | # Flask stuff: 139 | instance/ 140 | .webassets-cache 141 | 142 | # Scrapy stuff: 143 | .scrapy 144 | 145 | # Sphinx documentation 146 | docs/_build/ 147 | 148 | # PyBuilder 149 | target/ 150 | 151 | # Jupyter Notebook 152 | .ipynb_checkpoints 153 | 154 | # IPython 155 | profile_default/ 156 | ipython_config.py 157 | 158 | # pyenv 159 | .python-version 160 | 161 | # pipenv 162 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 163 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 164 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 165 | # install all needed dependencies. 166 | #Pipfile.lock 167 | 168 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 169 | __pypackages__/ 170 | 171 | # Celery stuff 172 | celerybeat-schedule 173 | celerybeat.pid 174 | 175 | # SageMath parsed files 176 | *.sage.py 177 | 178 | # Environments 179 | .env 180 | .venv 181 | env/ 182 | venv/ 183 | ENV/ 184 | env.bak/ 185 | venv.bak/ 186 | 187 | # Spyder project settings 188 | .spyderproject 189 | .spyproject 190 | 191 | # Rope project settings 192 | .ropeproject 193 | 194 | # mkdocs documentation 195 | /site 196 | 197 | # mypy 198 | .mypy_cache/ 199 | .dmypy.json 200 | dmypy.json 201 | 202 | # Pyre type checker 203 | .pyre/ 204 | 205 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/netbox_vcenter.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vCenter integration plugin for NetBox 2 | 3 | This plugin shows live data from vCenter clusters in NetBox, making it easier for administrators to make sure that reality reflects what is documented in NetBox. 4 | 5 | ## Compatibility 6 | 7 | This plugin in compatible with [NetBox](https://netbox.readthedocs.org/) 2.8 and later. NetBox 2.10 introduced breaking 8 | changes that make it unusable for my own use cases, so I will not be providing support for it. There is work being done 9 | to create a fork of NetBox that is friendlier to both network operators and contributors. My future work will be in 10 | support of that. 11 | -------------------------------------------------------------------------------- /netbox_vcenter/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.1.1' 2 | 3 | try: 4 | from extras.plugins import PluginConfig 5 | except ImportError: 6 | # Dummy for when importing outside of netbox 7 | class PluginConfig: 8 | pass 9 | 10 | 11 | class NetBoxVCenterConfig(PluginConfig): 12 | name = 'netbox_vcenter' 13 | verbose_name = 'vCenter' 14 | version = VERSION 15 | author = 'Sander Steffann' 16 | author_email = 'sander@steffann.nl' 17 | description = 'vCenter integration for NetBox' 18 | base_url = 'vcenter' 19 | required_settings = [] 20 | default_settings = { 21 | 'CACHE_TIMEOUT': 3600, 22 | 'CACHE_FAILURE_TIMEOUT': 600, 23 | 'REFRESH_WAIT': 15, 24 | } 25 | 26 | 27 | config = NetBoxVCenterConfig 28 | -------------------------------------------------------------------------------- /netbox_vcenter/background_tasks.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import time 4 | 5 | from cacheops import CacheMiss, cache 6 | from django.conf import settings 7 | from django_rq import job 8 | from pyVim import connect 9 | from pyVmomi import vim 10 | 11 | from netbox_vcenter.models import ClusterVCenter 12 | 13 | logger = logging.getLogger('netbox_vcenter') 14 | 15 | 16 | def get_virtual_machines(vcenter: ClusterVCenter): 17 | if not vcenter: 18 | return None 19 | 20 | logger.debug("Checking for VMs on {}".format(vcenter.server)) 21 | try: 22 | cache_key = get_cache_key(vcenter) 23 | vms = cache.get(cache_key) 24 | if vms != 'FAILED': 25 | logger.debug("Found cached VMs on {}".format(vcenter.server)) 26 | return vms 27 | except CacheMiss: 28 | # Get the VMs in the background worker, it will fill the cache 29 | logger.info("Initiating background task to retrieve VMs from {}".format(vcenter.server)) 30 | refresh_virtual_machines.delay(vcenter=vcenter) 31 | 32 | return None 33 | 34 | 35 | def get_nic_vlan(content, dvs_cache, portgroup_cache, vm, dev): 36 | dev_backing = dev.backing 37 | vlan_id = None 38 | 39 | if hasattr(dev_backing, 'port'): 40 | port_group_key = dev.backing.port.portgroupKey 41 | dvs_uuid = dev.backing.port.switchUuid 42 | if dvs_uuid in dvs_cache: 43 | dvs = dvs_cache[dvs_uuid] 44 | else: 45 | try: 46 | dvs = content.dvSwitchManager.QueryDvsByUuid(dvs_uuid) 47 | dvs_cache[dvs_uuid] = dvs 48 | except Exception: 49 | dvs = None 50 | 51 | if dvs: 52 | pg_obj = dvs.LookupDvPortGroup(port_group_key) 53 | vlan_id = str(pg_obj.config.defaultPortConfig.vlan.vlanId) 54 | else: 55 | portgroup = dev.backing.network.name 56 | vm_host = vm.runtime.host 57 | if vm_host in portgroup_cache: 58 | pgs = portgroup_cache[vm_host] 59 | else: 60 | pgs = vm_host.config.network.portgroup 61 | portgroup_cache[vm_host] = pgs 62 | 63 | for p in pgs: 64 | if portgroup in p.key: 65 | vlan_id = str(p.spec.vlanId) 66 | 67 | return vlan_id 68 | 69 | 70 | def get_objects_of_type(content, obj_type): 71 | view_mgr = content.viewManager.CreateContainerView(content.rootFolder, 72 | [obj_type], 73 | True) 74 | try: 75 | return list(view_mgr.view) 76 | finally: 77 | view_mgr.Destroy() 78 | 79 | 80 | def get_cache_key(vcenter: ClusterVCenter): 81 | raw_key = f'{vcenter.server}\t{vcenter.username}\t{vcenter.password}' 82 | key = hashlib.sha256(raw_key.encode('utf-8')).hexdigest()[-16] 83 | return key 84 | 85 | 86 | @job 87 | def refresh_virtual_machines(vcenter: ClusterVCenter, force=False): 88 | config = settings.PLUGINS_CONFIG['netbox_vcenter'] 89 | vcenter_cache_key = get_cache_key(vcenter) 90 | 91 | # Check whether this server has failed recently and shouldn't be retried yet 92 | try: 93 | cached_data = cache.get(vcenter_cache_key) 94 | if not force and cached_data == 'FAILED': 95 | logger.info("Skipping vCenter update; server {} failed recently".format(vcenter.server)) 96 | return 97 | 98 | if not force: 99 | logger.info("Skipping vCenter update; server {} already in cache".format(vcenter.server)) 100 | return cached_data 101 | except CacheMiss: 102 | pass 103 | 104 | service_instance = None 105 | try: 106 | logger.debug("Fetching VMs from {}".format(vcenter.server)) 107 | 108 | # Connect to the vCenter server 109 | if vcenter.validate_certificate: 110 | service_instance = connect.Connect(vcenter.server, user=vcenter.username, pwd=vcenter.password) 111 | else: 112 | service_instance = connect.ConnectNoSSL(vcenter.server, user=vcenter.username, pwd=vcenter.password) 113 | 114 | content = service_instance.RetrieveContent() 115 | 116 | vms = get_objects_of_type(content, vim.VirtualMachine) 117 | all_stats = { 118 | 'timestamp': time.time(), 119 | 'vms': {} 120 | } 121 | dvs_cache = {} 122 | portgroup_cache = {} 123 | for vm in vms: 124 | vm_stats = { 125 | 'power': None, 126 | 'vcpus': None, 127 | 'memory': None, 128 | 'disk': None, 129 | 'nics': [], 130 | } 131 | 132 | try: 133 | if vm.runtime.powerState: 134 | vm_stats['powered_on'] = vm.runtime.powerState == 'poweredOn' 135 | if vm.config.hardware.numCPU: 136 | vm_stats['vcpus'] = vm.config.hardware.numCPU 137 | if vm.config.hardware.memoryMB: 138 | vm_stats['memory'] = vm.config.hardware.memoryMB 139 | 140 | disk_devices = [device for device in vm.config.hardware.device 141 | if isinstance(device, vim.vm.device.VirtualDisk)] 142 | if disk_devices: 143 | # Sum and convert from KB to GB 144 | total_capacity = 0 145 | for device in disk_devices: 146 | total_capacity += device.capacityInKB 147 | vm_stats['disk'] = round(total_capacity / 1048576) 148 | 149 | for dev in vm.config.hardware.device: 150 | if isinstance(dev, vim.vm.device.VirtualEthernetCard): 151 | vlan = get_nic_vlan(content, dvs_cache, portgroup_cache, vm, dev) 152 | vm_stats['nics'].append({ 153 | 'label': dev.deviceInfo.label, 154 | 'mac_address': dev.macAddress, 155 | 'vlan': vlan, 156 | }) 157 | except Exception: 158 | logger.exception("Error while fetching virtual machine {} from {}".format(vm.name, vcenter.server)) 159 | continue 160 | 161 | # Collect all stats for returning 162 | all_stats['vms'][vm.name] = vm_stats 163 | 164 | # Cache a list of all VMs 165 | cache.set(vcenter_cache_key, all_stats, config['CACHE_TIMEOUT']) 166 | 167 | return all_stats 168 | except Exception: 169 | # Set a cookie in the cache so we don't keep retrying 170 | logger.exception("Error while fetching virtual machines from {}. " 171 | "Disabling checks for 5 minutes.".format(vcenter.server)) 172 | cache.set(vcenter_cache_key, 'FAILED', config['CACHE_FAILURE_TIMEOUT']) 173 | finally: 174 | if service_instance: 175 | connect.Disconnect(service_instance) 176 | -------------------------------------------------------------------------------- /netbox_vcenter/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from netbox_vcenter.models import ClusterVCenter 5 | from utilities.forms import BootstrapMixin 6 | 7 | 8 | class ClusterVCenterEditForm(BootstrapMixin, forms.ModelForm): 9 | new_password = forms.CharField( 10 | max_length=64, 11 | label='Password', 12 | widget=forms.PasswordInput(), 13 | help_text=_('This password is stored unencrypted in the database. ' 14 | 'Use a read-only account with limited privileges!'), 15 | ) 16 | 17 | class Meta: 18 | model = ClusterVCenter 19 | fields = ['server', 'validate_certificate', 'username'] 20 | 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | 24 | if self.instance and self.instance.password: 25 | # Hack the password field and make it non-required 26 | self.fields['new_password'].required = False 27 | self.fields['new_password'].widget.is_required = False 28 | if 'required' in self.fields['new_password'].widget.attrs: 29 | del self.fields['new_password'].widget.attrs['required'] 30 | self.fields['new_password'].widget.attrs['placeholder'] = 'current password hidden' 31 | 32 | def _post_clean(self): 33 | # Update the password if it is provided 34 | if self.cleaned_data['new_password']: 35 | self.instance.password = self.cleaned_data['new_password'] 36 | 37 | return super()._post_clean() 38 | -------------------------------------------------------------------------------- /netbox_vcenter/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjm-steffann/netbox-vcenter/0be55315b7a6c60e2b30198ccd6cfa3d02c2cf14/netbox_vcenter/management/__init__.py -------------------------------------------------------------------------------- /netbox_vcenter/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjm-steffann/netbox-vcenter/0be55315b7a6c60e2b30198ccd6cfa3d02c2cf14/netbox_vcenter/management/commands/__init__.py -------------------------------------------------------------------------------- /netbox_vcenter/management/commands/update_vcenter_cache.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from netbox_vcenter.background_tasks import refresh_virtual_machines 4 | from netbox_vcenter.models import ClusterVCenter 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Update the cache of vCenter information" 9 | 10 | def handle(self, verbosity, *args, **kwargs): 11 | for vcenter in ClusterVCenter.objects.all(): 12 | if verbosity >= 1: 13 | self.stdout.write(f"Scheduling cache update for {vcenter.cluster.name}") 14 | refresh_virtual_machines.delay(vcenter=vcenter, force=True) 15 | -------------------------------------------------------------------------------- /netbox_vcenter/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-02 23:08 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import netbox_vcenter.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ('virtualization', '0014_standardize_description'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='ClusterVCenter', 19 | fields=[ 20 | ('id', models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False 24 | )), 25 | ('server', models.CharField( 26 | max_length=64, 27 | validators=[netbox_vcenter.validators.HostnameAddressValidator()] 28 | )), 29 | ('validate_certificate', models.BooleanField( 30 | default=True 31 | )), 32 | ('username', models.CharField( 33 | max_length=64 34 | )), 35 | ('password', models.CharField( 36 | max_length=64 37 | )), 38 | ('cluster', models.OneToOneField( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name='vcenter', 41 | to='virtualization.Cluster' 42 | )), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /netbox_vcenter/migrations/0002_auto_20200403_0215.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-03 00:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_vcenter', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='clustervcenter', 15 | options={'verbose_name': 'vCenter configuration', 'verbose_name_plural': 'vCenter configurations'}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /netbox_vcenter/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjm-steffann/netbox-vcenter/0be55315b7a6c60e2b30198ccd6cfa3d02c2cf14/netbox_vcenter/migrations/__init__.py -------------------------------------------------------------------------------- /netbox_vcenter/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from netbox_vcenter.validators import HostnameAddressValidator 5 | from virtualization.models import Cluster 6 | 7 | 8 | class ClusterVCenter(models.Model): 9 | cluster = models.OneToOneField( 10 | to=Cluster, 11 | on_delete=models.CASCADE, 12 | related_name='vcenter', 13 | ) 14 | server = models.CharField( 15 | verbose_name=_('vCenter Server'), 16 | max_length=64, 17 | validators=[HostnameAddressValidator()], 18 | ) 19 | validate_certificate = models.BooleanField( 20 | verbose_name=_('validate certificate'), 21 | default=True, 22 | ) 23 | username = models.CharField( 24 | verbose_name=_('username'), 25 | max_length=64, 26 | ) 27 | password = models.CharField( 28 | verbose_name=_('password'), 29 | max_length=64, 30 | ) 31 | 32 | class Meta: 33 | verbose_name = _('vCenter configuration') 34 | verbose_name_plural = _('vCenter configurations') 35 | 36 | def __str__(self): 37 | return self.cluster.name 38 | 39 | def get_absolute_url(self): 40 | return self.cluster.get_absolute_url() 41 | -------------------------------------------------------------------------------- /netbox_vcenter/navigation.py: -------------------------------------------------------------------------------- 1 | # from extras.plugins import PluginMenuItem 2 | # 3 | # menu_items = ( 4 | # PluginMenuItem( 5 | # link='plugins:netbox_vcenter:compare', 6 | # link_text='Compare with vCenter', 7 | # ), 8 | # ) 9 | -------------------------------------------------------------------------------- /netbox_vcenter/template_content.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.contrib.auth.context_processors import PermWrapper 4 | from django.template.context_processors import csrf 5 | 6 | from extras.plugins import PluginTemplateExtension 7 | from netbox_vcenter.background_tasks import get_virtual_machines 8 | from virtualization.models import VirtualMachine 9 | 10 | 11 | # noinspection PyAbstractClass 12 | class ClusterInfo(PluginTemplateExtension): 13 | model = 'virtualization.cluster' 14 | 15 | def left_page(self): 16 | """ 17 | An info-box with edit button for the vCenter settings 18 | """ 19 | return self.render('netbox_vcenter/cluster/vcenter_info.html', { 20 | 'perms': PermWrapper(self.context['request'].user) 21 | }) 22 | 23 | 24 | # noinspection PyAbstractClass 25 | class VirtualMachineInfo(PluginTemplateExtension): 26 | model = 'virtualization.virtualmachine' 27 | 28 | def buttons(self): 29 | context = { 30 | 'perms': PermWrapper(self.context['request'].user), 31 | } 32 | context.update(csrf(self.context['request'])) 33 | return self.render('netbox_vcenter/virtualmachine/vcenter_refresh.html', context) 34 | 35 | def right_page(self): 36 | """ 37 | An info-box with information from vCenter 38 | """ 39 | vm = self.context['object'] # type: VirtualMachine 40 | 41 | try: 42 | all_stats = get_virtual_machines(vm.cluster.vcenter) 43 | vcenter_timestamp = datetime.fromtimestamp(all_stats['timestamp']) 44 | vcenter_resources = all_stats['vms'][vm.name] 45 | except Exception: 46 | return '' 47 | 48 | context = { 49 | 'perms': PermWrapper(self.context['request'].user), 50 | 'vcenter_timestamp': vcenter_timestamp, 51 | 'vcenter_resources': vcenter_resources, 52 | } 53 | context.update(csrf(self.context['request'])) 54 | return self.render('netbox_vcenter/virtualmachine/vcenter_resources.html', context) 55 | 56 | 57 | template_extensions = [ClusterInfo, VirtualMachineInfo] 58 | -------------------------------------------------------------------------------- /netbox_vcenter/templates/netbox_vcenter/cluster/vcenter_info.html: -------------------------------------------------------------------------------- 1 | {% if perms.netbox_vcenter.view_clustervcenter %} 2 |
3 |
4 | vCenter 5 |
6 | {% if object.vcenter %} 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | 25 | 26 | 27 |
vCenter Server 12 | {{ object.vcenter.server }} 14 | 15 | {% if not object.vcenter.validate_certificate %} 16 | (insecure) 17 | {% endif %} 18 |
Username 23 | {{ object.vcenter.username }} 24 |
28 | {% else %} 29 |
30 | No vCenter settings configured 31 |
32 | {% endif %} 33 | 66 |
67 | {% endif %} 68 | -------------------------------------------------------------------------------- /netbox_vcenter/templates/netbox_vcenter/virtualmachine/vcenter_refresh.html: -------------------------------------------------------------------------------- 1 | {% if perms.virtualization.view_virtualmachine %} 2 |
3 | {% csrf_token %} 4 | 5 | 10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /netbox_vcenter/templates/netbox_vcenter/virtualmachine/vcenter_resources.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 | 3 |
4 |
5 | vCenter Resources 6 | cached: {{ vcenter_timestamp|date:"DATETIME_FORMAT" }} 7 |
8 |
9 | {% csrf_token %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | 44 | 45 | 46 | 47 | 54 | 67 | 81 | 82 | 83 | 84 | 91 | 104 | 118 | 119 | 120 | 121 | 124 | 133 | 134 | 135 |
NetBoxvCenter
Virtual CPUs{{ object.vcpus|placeholder }} 22 | {% if object.vcpus != vcenter_resources.vcpus %} 23 | 24 | {{ vcenter_resources.vcpus|placeholder }} 25 | 26 | {% else %} 27 | {{ vcenter_resources.vcpus|placeholder }} 28 | {% endif %} 29 | 31 | {% if vcenter_resources.vcpus and object.vcpus != vcenter_resources.vcpus %} 32 | {% if perms.virtualization.change_virtualmachine %} 33 |
34 | 35 | 40 |
41 | {% endif %} 42 | {% endif %} 43 |
Memory 48 | {% if object.memory %} 49 | {{ object.memory }} MB 50 | {% else %} 51 | 52 | {% endif %} 53 | 55 | {% if vcenter_resources.memory %} 56 | {% if object.memory != vcenter_resources.memory %} 57 | 58 | {{ vcenter_resources.memory }} MB 59 | 60 | {% else %} 61 | {{ vcenter_resources.memory }} MB 62 | {% endif %} 63 | {% else %} 64 | 65 | {% endif %} 66 | 68 | {% if vcenter_resources.memory and object.memory != vcenter_resources.memory %} 69 | {% if perms.virtualization.change_virtualmachine %} 70 |
71 | 72 | 77 |
78 | {% endif %} 79 | {% endif %} 80 |
Disk Space 85 | {% if object.disk %} 86 | {{ object.disk }} GB 87 | {% else %} 88 | 89 | {% endif %} 90 | 92 | {% if vcenter_resources.disk %} 93 | {% if object.disk != vcenter_resources.disk %} 94 | 95 | {{ vcenter_resources.disk }} GB 96 | 97 | {% else %} 98 | {{ vcenter_resources.disk }} GB 99 | {% endif %} 100 | {% else %} 101 | 102 | {% endif %} 103 | 105 | {% if vcenter_resources.disk and object.disk != vcenter_resources.disk %} 106 | {% if perms.virtualization.change_virtualmachine %} 107 |
108 | 109 | 114 |
115 | {% endif %} 116 | {% endif %} 117 |
Network interfaces 122 | {{ object.interfaces.all|length }} 123 | 125 | {% if object.interfaces.all|length != vcenter_resources.nics|length %} 126 | 127 | {{ vcenter_resources.nics|length }} 128 | 129 | {% else %} 130 | {{ vcenter_resources.nics|length }} 131 | {% endif %} 132 |
136 |
137 |
138 | 139 | 205 | -------------------------------------------------------------------------------- /netbox_vcenter/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ClusterVCenterDeleteView, ClusterVCenterEditView, VirtualMachineRefresh, VirtualMachineUpdate 4 | 5 | urlpatterns = [ 6 | path('clusters//edit/', ClusterVCenterEditView.as_view(), name='cluster_vcenter_edit'), 7 | path('clusters//delete/', ClusterVCenterDeleteView.as_view(), name='cluster_vcenter_delete'), 8 | path('virtualhosts//update//', VirtualMachineUpdate.as_view(), 9 | name='virtualmachine_update'), 10 | path('virtualhosts//refresh/', VirtualMachineRefresh.as_view(), 11 | name='virtualmachine_refresh'), 12 | 13 | # path('compare/', CompareVCenterView.as_view(), name='compare'), 14 | ] 15 | -------------------------------------------------------------------------------- /netbox_vcenter/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.validators import RegexValidator, URLValidator 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class HostnameValidator(RegexValidator): 6 | regex = '^(' + URLValidator.host_re + ')$' 7 | message = _('Enter a valid hostname.') 8 | 9 | 10 | class HostnameAddressValidator(RegexValidator): 11 | regex = '^(' + URLValidator.ipv4_re + '|' + URLValidator.ipv6_re + '|' + URLValidator.host_re + ')$' 12 | message = _('Enter a valid hostname or IP address.') 13 | -------------------------------------------------------------------------------- /netbox_vcenter/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.contrib.auth.mixins import PermissionRequiredMixin 6 | from django.http import Http404, HttpResponseBadRequest 7 | from django.shortcuts import get_object_or_404, redirect 8 | from django.views.generic import View 9 | from rq.job import Job 10 | 11 | from netbox_vcenter.background_tasks import refresh_virtual_machines 12 | from netbox_vcenter.forms import ClusterVCenterEditForm 13 | from netbox_vcenter.models import ClusterVCenter 14 | from utilities.views import GetReturnURLMixin, ObjectDeleteView, ObjectEditView 15 | from virtualization.models import Cluster, VirtualMachine 16 | 17 | logger = logging.getLogger('netbox_vcenter') 18 | 19 | 20 | class ClusterVCenterObjectMixin: 21 | def get_object(self, kwargs): 22 | if 'cluster_id' not in kwargs: 23 | raise Http404 24 | 25 | cluster = get_object_or_404(Cluster, pk=kwargs['cluster_id']) 26 | 27 | try: 28 | return ClusterVCenter.objects.get(cluster=cluster) 29 | except ClusterVCenter.DoesNotExist: 30 | return ClusterVCenter(cluster=cluster) 31 | 32 | 33 | class ClusterVCenterEditView(PermissionRequiredMixin, ClusterVCenterObjectMixin, ObjectEditView): 34 | permission_required = 'change_clustervcenter' 35 | model = ClusterVCenter 36 | model_form = ClusterVCenterEditForm 37 | 38 | 39 | class ClusterVCenterDeleteView(PermissionRequiredMixin, ClusterVCenterObjectMixin, ObjectDeleteView): 40 | permission_required = 'delete_clustervcenter' 41 | model = ClusterVCenter 42 | 43 | 44 | class VirtualMachineRefresh(PermissionRequiredMixin, GetReturnURLMixin, View): 45 | permission_required = 'view_virtualmachine' 46 | 47 | def post(self, request, virtualmachine_id): 48 | config = settings.PLUGINS_CONFIG['netbox_vcenter'] 49 | 50 | vm = get_object_or_404(VirtualMachine, pk=virtualmachine_id) 51 | vcenter = vm.cluster.vcenter 52 | 53 | logger.info("Forcing background task to retrieve VMs from {}".format(vcenter.server)) 54 | job = refresh_virtual_machines.delay(vcenter=vcenter, force=True) # type: Job 55 | 56 | delay = 0.2 57 | max_wait = config['REFRESH_WAIT'] 58 | max_loops = max_wait / delay 59 | loops = 0 60 | while job.is_queued or job.is_started: 61 | if loops > max_loops: 62 | break 63 | 64 | time.sleep(delay) 65 | loops += 1 66 | 67 | return redirect(self.get_return_url(request, vm)) 68 | 69 | 70 | class VirtualMachineUpdate(PermissionRequiredMixin, GetReturnURLMixin, View): 71 | permission_required = 'change_virtualmachine' 72 | 73 | def post(self, request, virtualmachine_id, field): 74 | if field not in ['vcpus', 'memory', 'disk']: 75 | raise Http404 76 | 77 | if field not in request.POST: 78 | return HttpResponseBadRequest(f'No value provided for {field}') 79 | 80 | try: 81 | value = int(request.POST[field]) 82 | except ValueError: 83 | return HttpResponseBadRequest(f'Value of {field} must be an integer') 84 | 85 | vm = get_object_or_404(VirtualMachine, pk=virtualmachine_id) 86 | setattr(vm, field, value) 87 | vm.save() 88 | 89 | messages.info(request, f"Updated {vm._meta.get_field(field).verbose_name} of {vm.name}") 90 | 91 | return redirect(self.get_return_url(request, vm)) 92 | 93 | # class CompareVCenterView(View): 94 | # def __init__(self, **kwargs): 95 | # super().__init__(**kwargs) 96 | # 97 | # self.content = None 98 | # self._pgs_cache = {} 99 | # 100 | # def get(self, request): 101 | # try: 102 | # self.content = service_instance.RetrieveContent() 103 | # 104 | # output = '' 105 | # vms = self.get_objects_of_type(vim.VirtualMachine) 106 | # for vm in vms: 107 | # output += f'VM {vm.name}\n' 108 | # for dev in vm.config.hardware.device: 109 | # if isinstance(dev, vim.vm.device.VirtualEthernetCard): 110 | # vlan = self.get_nic_vlan(vm, dev) 111 | # output += f' NIC {dev.deviceInfo.label} [{dev.macAddress}] = {vlan}\n' 112 | # 113 | # return HttpResponse(output, "text/plain") 114 | # finally: 115 | # connect.Disconnect(service_instance) 116 | # 117 | # def get_objects_of_type(self, obj_type): 118 | # view_mgr = self.content.viewManager.CreateContainerView(self.content.rootFolder, 119 | # [obj_type], 120 | # True) 121 | # try: 122 | # return list(view_mgr.view) 123 | # finally: 124 | # view_mgr.Destroy() 125 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 41.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = netbox-vcenter 3 | version = attr: netbox_vcenter.VERSION 4 | description = vCenter integration for NetBox 5 | long_description = file: README.md 6 | author = Sander Steffann 7 | author_email = sander@steffann.nl 8 | url = https://github.com/sjm-steffann/netbox-vcenter 9 | license = Apache 2.0 10 | license_file = LICENSE.txt 11 | classifiers = 12 | Development Status :: 2 - Pre-Alpha 13 | Framework :: Django 14 | Framework :: Django :: 3.0 15 | License :: OSI Approved :: Apache Software License 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.6 18 | 19 | [options] 20 | zip_safe = False 21 | packages = find: 22 | python_requires = >=3.6 23 | install_requires = 24 | setuptools 25 | pyvmomi ~= 6.7.0 26 | 27 | [options.package_data] 28 | netbox_vcenter = 29 | static/netbox_vcenter/*.css 30 | templates/*.html 31 | templates/*/*.html 32 | templates/*/*/*.html 33 | templates/*/*/*/*.html 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import setuptools 4 | 5 | if __name__ == "__main__": 6 | setuptools.setup() 7 | --------------------------------------------------------------------------------