|
| 1 | +"""Vcenter module |
| 2 | +
|
| 3 | +This module contains the methods to connect to a Vcenter server to |
| 4 | +retrieve the list of VMs. |
| 5 | +""" |
| 6 | +# pylint: disable=no-name-in-module |
| 7 | +# pylint: disable=no-self-argument |
| 8 | + |
| 9 | +import asyncio |
| 10 | +import logging |
| 11 | +from typing import Dict, List, Optional, Union |
| 12 | +from urllib.parse import urlparse |
| 13 | +import ssl |
| 14 | +from pyVim.connect import Disconnect, SmartConnect |
| 15 | +from pyVmomi import vim, vmodl |
| 16 | + |
| 17 | + |
| 18 | +from pydantic import BaseModel, validator, Field |
| 19 | + |
| 20 | +from suzieq.poller.controller.inventory_async_plugin import \ |
| 21 | + InventoryAsyncPlugin |
| 22 | +from suzieq.poller.controller.source.base_source import Source, SourceModel |
| 23 | +from suzieq.shared.utils import get_sensitive_data |
| 24 | +from suzieq.shared.exceptions import InventorySourceError, SensitiveLoadError |
| 25 | + |
| 26 | +_DEFAULT_PORTS = {'https': 443} |
| 27 | + |
| 28 | +logger = logging.getLogger(__name__) |
| 29 | + |
| 30 | + |
| 31 | +class VcenterServerModel(BaseModel): |
| 32 | + """Model containing data to connect with vcenter server.""" |
| 33 | + host: str |
| 34 | + port: str |
| 35 | + |
| 36 | + class Config: |
| 37 | + """pydantic configuration |
| 38 | + """ |
| 39 | + extra = 'forbid' |
| 40 | + |
| 41 | + |
| 42 | +class VcenterSourceModel(SourceModel): |
| 43 | + """Vcenter source validation model.""" |
| 44 | + username: str |
| 45 | + password: str |
| 46 | + attributes: Optional[List] = Field(default=['suzieq']) |
| 47 | + period: Optional[int] = Field(default=3600) |
| 48 | + ssl_verify: Optional[bool] = Field(alias='ssl-verify') |
| 49 | + server: Union[str, VcenterServerModel] = Field(alias='url') |
| 50 | + run_once: Optional[bool] = Field(default=False, alias='run_once') |
| 51 | + |
| 52 | + @validator('server', pre=True) |
| 53 | + def validate_and_set(cls, url, values): |
| 54 | + """Validate the field 'url' and set the correct parameters |
| 55 | + """ |
| 56 | + if isinstance(url, str): |
| 57 | + url_data = urlparse(url) |
| 58 | + host = url_data.hostname |
| 59 | + if not host: |
| 60 | + raise ValueError(f'Unable to parse hostname {url}') |
| 61 | + port = url_data.port or _DEFAULT_PORTS.get("https") |
| 62 | + if not port: |
| 63 | + raise ValueError(f'Unable to parse port {url}') |
| 64 | + server = VcenterServerModel(host=host, port=port) |
| 65 | + ssl_verify = values['ssl_verify'] |
| 66 | + if ssl_verify is None: |
| 67 | + ssl_verify = True |
| 68 | + values['ssl_verify'] = ssl_verify |
| 69 | + return server |
| 70 | + elif isinstance(url, VcenterServerModel): |
| 71 | + return url |
| 72 | + else: |
| 73 | + raise ValueError('Unknown input type') |
| 74 | + |
| 75 | + @validator('password') |
| 76 | + def validate_password(cls, password): |
| 77 | + """checks if the password can be load as sensible data |
| 78 | + """ |
| 79 | + try: |
| 80 | + if password == 'ask': |
| 81 | + return password |
| 82 | + return get_sensitive_data(password) |
| 83 | + except SensitiveLoadError as e: |
| 84 | + raise ValueError(e) |
| 85 | + |
| 86 | + |
| 87 | +class Vcenter(Source, InventoryAsyncPlugin): |
| 88 | + """This class is used to dynamically retrieve the inventory |
| 89 | + from Vcenter |
| 90 | + """ |
| 91 | + def __init__(self, config_data: dict, validate: bool = True) -> None: |
| 92 | + self._status = 'init' |
| 93 | + self._server: VcenterServerModel = None |
| 94 | + self._session = None |
| 95 | + |
| 96 | + super().__init__(config_data, validate) |
| 97 | + |
| 98 | + @classmethod |
| 99 | + def get_data_model(cls): |
| 100 | + return VcenterSourceModel |
| 101 | + |
| 102 | + def _load(self, input_data): |
| 103 | + # load the server class from the dictionary |
| 104 | + if not self._validate: |
| 105 | + input_data['server'] = VcenterServerModel.construct( |
| 106 | + **input_data.pop('url', {})) |
| 107 | + input_data['ssl_verify'] = input_data.pop('ssl-verify', False) |
| 108 | + super()._load(input_data) |
| 109 | + if self._data.password == 'ask': |
| 110 | + self._data.password = get_sensitive_data( |
| 111 | + 'ask', f'{self.name} Insert vcenter password: ' |
| 112 | + ) |
| 113 | + self._server = self._data.server |
| 114 | + if not self._auth: |
| 115 | + raise InventorySourceError( |
| 116 | + f"{self.name} Vcenter must have an " |
| 117 | + "'auth' set in the 'namespaces' section") |
| 118 | + |
| 119 | + def _init_session(self): |
| 120 | + """Initialize the session property""" |
| 121 | + context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) |
| 122 | + context.verify_mode = ssl.CERT_REQUIRED |
| 123 | + if not self._data.ssl_verify: |
| 124 | + context.verify_mode = ssl.CERT_NONE |
| 125 | + |
| 126 | + try: |
| 127 | + self._session = SmartConnect( |
| 128 | + host=self._server.host, |
| 129 | + port=self._server.port, |
| 130 | + user=self._data.username, |
| 131 | + pwd=self._data.password, |
| 132 | + sslContext=context |
| 133 | + ) |
| 134 | + except Exception as e: |
| 135 | + self._session = None |
| 136 | + raise InventorySourceError( |
| 137 | + f"Failed to connect to VCenter: {str(e)}") |
| 138 | + |
| 139 | + def _get_custom_keys(self, content, attribute_names): |
| 140 | + """Retrieve custom attribute keys based on their names.""" |
| 141 | + all_custom_fields = {field.name: field.key |
| 142 | + for field in content.customFieldsManager.field} |
| 143 | + return [ |
| 144 | + all_custom_fields[name] |
| 145 | + for name in attribute_names |
| 146 | + if name in all_custom_fields |
| 147 | + ] |
| 148 | + |
| 149 | + def _create_filter_spec(self, view): |
| 150 | + """Return a FilterSpec based on provided view and attribute keys.""" |
| 151 | + traversal_spec = vmodl.query.PropertyCollector.TraversalSpec( |
| 152 | + name='traverseEntities', path='view', skip=False, |
| 153 | + type=vim.view.ContainerView, |
| 154 | + selectSet=[vmodl.query.PropertyCollector.SelectionSpec( |
| 155 | + name='traverseEntities')]) |
| 156 | + prop_set = vmodl.query.PropertyCollector.PropertySpec( |
| 157 | + all=False, type=vim.VirtualMachine) |
| 158 | + prop_set.pathSet = ['name', 'guest.ipAddress', 'customValue'] |
| 159 | + obj_spec = vmodl.query.PropertyCollector.ObjectSpec( |
| 160 | + obj=view, selectSet=[traversal_spec]) |
| 161 | + filter_spec = vmodl.query.PropertyCollector.FilterSpec() |
| 162 | + filter_spec.objectSet = [obj_spec] |
| 163 | + filter_spec.propSet = [prop_set] |
| 164 | + return filter_spec |
| 165 | + |
| 166 | + async def get_inventory_list(self) -> List: |
| 167 | + """ |
| 168 | + Retrieve VMs that have any specified custom attribute names. |
| 169 | +
|
| 170 | + This method uses vSphere's Property Collector to fetch only |
| 171 | + properties that are required. This is a lot faster than |
| 172 | + fetching the entire inventory and filtering on attributes. |
| 173 | + """ |
| 174 | + if not self._session: |
| 175 | + self._init_session() |
| 176 | + |
| 177 | + content = self._session.RetrieveContent() |
| 178 | + view = content.viewManager.CreateContainerView( |
| 179 | + content.rootFolder, [vim.VirtualMachine], True) |
| 180 | + attribute_keys = self._get_custom_keys(content, self._data.attributes) |
| 181 | + |
| 182 | + filter_spec = self._create_filter_spec(view) |
| 183 | + retrieve_options = vmodl.query.PropertyCollector.RetrieveOptions() |
| 184 | + result = content.propertyCollector.RetrievePropertiesEx( |
| 185 | + [filter_spec], retrieve_options) |
| 186 | + vms_with_ip = {} |
| 187 | + while result: |
| 188 | + for obj in result.objects: |
| 189 | + vm_name = None |
| 190 | + vm_ip = None |
| 191 | + has_custom_attr = False |
| 192 | + for prop in obj.propSet: |
| 193 | + if prop.name == 'name': |
| 194 | + vm_name = prop.val |
| 195 | + elif prop.name == 'guest.ipAddress' and prop.val: |
| 196 | + vm_ip = prop.val |
| 197 | + elif prop.name == 'customValue': |
| 198 | + has_custom_attr = any( |
| 199 | + cv.key in attribute_keys for cv in prop.val) |
| 200 | + if has_custom_attr and vm_ip: |
| 201 | + vms_with_ip[vm_name] = vm_ip |
| 202 | + |
| 203 | + if hasattr(result, 'token') and result.token: |
| 204 | + property_collector = content.propertyCollector |
| 205 | + result = property_collector.ContinueRetrievePropertiesEx( |
| 206 | + token=result.token) |
| 207 | + else: |
| 208 | + break |
| 209 | + |
| 210 | + view.Destroy() |
| 211 | + logger.info( |
| 212 | + f'Vcenter: Retrieved {len(vms_with_ip)} VMs with IPs') |
| 213 | + return vms_with_ip |
| 214 | + |
| 215 | + def parse_inventory(self, inventory_list: dict) -> Dict: |
| 216 | + """parse the raw inventory collected from the server and generates |
| 217 | + a new inventory with only the required information. |
| 218 | +
|
| 219 | + Args: |
| 220 | + raw_inventory: raw inventory received from vcenter. |
| 221 | +
|
| 222 | + Returns: A dict containing the inventory. |
| 223 | + """ |
| 224 | + inventory = {} |
| 225 | + for name, ip in inventory_list.items(): |
| 226 | + namespace = self._namespace |
| 227 | + inventory[f'{namespace}.{ip}'] = { |
| 228 | + 'address': ip, |
| 229 | + 'namespace': namespace, |
| 230 | + 'hostname': name, |
| 231 | + } |
| 232 | + logger.info( |
| 233 | + f'Vcenter: Acting on inventory of {len(inventory)} devices') |
| 234 | + return inventory |
| 235 | + |
| 236 | + async def _execute(self): |
| 237 | + while True: |
| 238 | + inventory_list = await self.get_inventory_list() |
| 239 | + tmp_inventory = self.parse_inventory(inventory_list) |
| 240 | + self.set_inventory(tmp_inventory) |
| 241 | + if self._run_once: |
| 242 | + break |
| 243 | + await asyncio.sleep(self._data.period) |
| 244 | + |
| 245 | + async def _stop(self): |
| 246 | + if self._session: |
| 247 | + Disconnect(self._session) |
0 commit comments