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