Skip to content

Commit e31484a

Browse files
authored
Merge pull request #939 from tgupta3/vcenter-inventory
Add vcenter as inventory source
2 parents 3406c5e + f21e0f1 commit e31484a

File tree

7 files changed

+374
-1
lines changed

7 files changed

+374
-1
lines changed

poetry.lock

Lines changed: 32 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ jellyfish = "~0.10"
5454
altair = '>3.2, <5.0'
5555
pydantic = '< 2.0'
5656
numpy = '~1.20'
57+
pyvmomi = "^8.0.2.0.1"
5758

5859
[tool.poetry.dev-dependencies]
5960
pylint = "*"

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ markers =
112112
controller_source_ansible
113113
controller_source_native
114114
controller_source_netbox
115+
controller_source_vcenter
115116
controller_unit_tests
116117

117118
# schema
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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)

tests/unit/poller/controller/sources/vcenter/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)