Skip to content

Commit f5e5f1b

Browse files
committed
Add vcenter as inventory source
Signed-off-by: Tushar <tgupta3@users.noreply.github.com>
1 parent 9362ae2 commit f5e5f1b

File tree

7 files changed

+353
-0
lines changed

7 files changed

+353
-0
lines changed

poetry.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ packaging = "~21.3"
5252
psutil = "~5.9.4"
5353
jellyfish = "~0.10"
5454
altair = '>3.2, <5.0'
55+
<<<<<<< HEAD
5556
pydantic = '< 2.0'
5657
numpy = '~1.20'
58+
=======
59+
pyvmomi = "^8.0.2.0.1"
60+
>>>>>>> 5f7cedbc37 (Add vcenter as inventory source)
5761

5862
[tool.poetry.dev-dependencies]
5963
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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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)

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

Whitespace-only changes.

0 commit comments

Comments
 (0)