|
| 1 | +#!/usr/bin/env python |
| 2 | +""" |
| 3 | +Copyright (c) 2023 VMware, Inc. All Rights Reserved. |
| 4 | +
|
| 5 | +Sample monitoring for changes in the MAC addresses of VMs. |
| 6 | +
|
| 7 | +The monitor uses the `PropertyCollector` API to detect changes in the MAC |
| 8 | +addresses of VMs. The `PropertyCollector` is initialized with a `ContainerView` |
| 9 | +of all VMs. As we can observe MAC addresses either from the virtual ethernet |
| 10 | +cards or through the guest the property spec includes all VM virtual devices - |
| 11 | +`config.hardware.device` and guest networks - `guest.net`. As ethernet cards |
| 12 | +are only one of many possible virtual devices, we will receive spurious updates |
| 13 | +related to other hardware. Also DHCP changes will update the `guest.net` values |
| 14 | +even when the MAC and IP addresses do not change. To mitigate the spurious |
| 15 | +updates the property collector output is filtered through a cache of currently |
| 16 | +known values that only lets through real changes to MAC and IP addresses. |
| 17 | +
|
| 18 | +The code can be enhanced to run in a background executor and provide query |
| 19 | +capability to query the cache as necessary. |
| 20 | +""" |
| 21 | + |
| 22 | + |
| 23 | +import time |
| 24 | +from pyVmomi import vim, vmodl |
| 25 | +from pyVim.connect import Disconnect |
| 26 | +from tools import cli, service_instance |
| 27 | + |
| 28 | +DEVICES_PROP_PATH = "config.hardware.device" |
| 29 | +GUEST_NET_PROP_PATH = "guest.net" |
| 30 | +NAME_PROP_PATH = "name" |
| 31 | + |
| 32 | + |
| 33 | +class VMDetails: |
| 34 | + """ |
| 35 | + Physical Networks Address details of a VM. Contains vm name and a map |
| 36 | + of device key to MAC address. |
| 37 | + """ |
| 38 | + def __init__(self, vm_name: str, vnic: dict[int:str], guest_net: dict[str:list[str]]): |
| 39 | + """ Create a new VM Details |
| 40 | + vm_name: The name of the VM |
| 41 | + vm_mac_addresses: A map of device key to MAC address |
| 42 | + vnic: A map of device key to mac address |
| 43 | + guest_net: A map of mac address to ip addresses |
| 44 | + """ |
| 45 | + self.vm_name = vm_name |
| 46 | + self.vnic = vnic |
| 47 | + self.guest_net = guest_net |
| 48 | + |
| 49 | + |
| 50 | +class VmMacChangeListener: |
| 51 | + """ Listens for changes in the mac addresses of VMs """ |
| 52 | + def update_vm(self, vm_id: str, vm_name: str, |
| 53 | + vnic: dict[int:str], guest_net: dict[str:list[str]]): |
| 54 | + """ Update the VM details |
| 55 | + vm_id: The id of the VM |
| 56 | + vm_name: The name of the VM |
| 57 | + vnic: A map of device key to mac address |
| 58 | + guest_net: A map of mac address to ip addresses |
| 59 | + """ |
| 60 | + def remove_vm(self, vm_id: str): |
| 61 | + """ Remove a VM from the list of VMs |
| 62 | + vm_id: The id of the VM |
| 63 | + """ |
| 64 | + |
| 65 | + |
| 66 | +class VmMacChangePrinter: |
| 67 | + """ Prints changes in the mac addresses of VMs """ |
| 68 | + def update_vm(self, vm_id: str, vm_name: str, |
| 69 | + vnic: dict[int:str], guest_net: dict[str:list[str]]): |
| 70 | + print(f"VM '{vm_name}' ({vm_id}) has mac addresses\ |
| 71 | + \n\tVNICS: {vnic}\n\tGUEST_NET: {guest_net}") |
| 72 | + |
| 73 | + def remove_vm(self, vm_id: str): |
| 74 | + print(f"VM {vm_id} has been removed") |
| 75 | + |
| 76 | + |
| 77 | +class VmMacCache(VmMacChangeListener): |
| 78 | + """ |
| 79 | + This cache removes spurious updates to MAC and IP addresses. It listens for |
| 80 | + changes in the MAC addresses of VMs, updates the cache of VM Mac addresses |
| 81 | + when a real change occurs and notifies a nested VmMacChangeListener. The |
| 82 | + cache is a map of VM id to VMDetails. |
| 83 | + """ |
| 84 | + def __init__(self, nested: VmMacChangeListener): |
| 85 | + """ Create a new VM Mac Change Cache |
| 86 | + nested: The next listener to notify of changes |
| 87 | + """ |
| 88 | + self.vm_cache: dict[str, VMDetails] = {} |
| 89 | + self.nested = nested |
| 90 | + |
| 91 | + def update_vm(self, vm_id: str, vm_name: str, |
| 92 | + vnic: dict[int:str], guest_net: dict[str:list[str]]): |
| 93 | + """ Update the VM details. |
| 94 | + vm_id: The id of the VM |
| 95 | + vm_name: The name of the VM |
| 96 | + vnic: A map of device key to mac address |
| 97 | + guest_net: A map of mac address to ip addresses |
| 98 | + """ |
| 99 | + if vm_id in self.vm_cache: |
| 100 | + updated = False |
| 101 | + if vm_name and self.vm_cache[vm_id].vm_name != vm_name: |
| 102 | + self.vm_cache[vm_id].vm_name = vm_name |
| 103 | + updated = True |
| 104 | + if vnic and self.vm_cache[vm_id].vnic != vnic: |
| 105 | + self.vm_cache[vm_id].vnic = vnic |
| 106 | + updated = True |
| 107 | + if guest_net and self.vm_cache[vm_id].guest_net != guest_net: |
| 108 | + self.vm_cache[vm_id].guest_net = guest_net |
| 109 | + updated = True |
| 110 | + if updated: |
| 111 | + cached = self.vm_cache[vm_id] |
| 112 | + self.nested.update_vm(vm_id, cached.vm_name, cached.vnic, cached.guest_net) |
| 113 | + else: |
| 114 | + self.vm_cache[vm_id] = VMDetails(vm_name, vnic, guest_net) |
| 115 | + self.nested.update_vm(vm_id, vm_name, vnic, guest_net) |
| 116 | + |
| 117 | + def remove_vm(self, vm_id: str): |
| 118 | + """ Remove a VM from the list of VMs |
| 119 | + vm_id: The id of the VM |
| 120 | + """ |
| 121 | + if vm_id in self.vm_cache: |
| 122 | + del self.vm_cache[vm_id] |
| 123 | + self.nested.remove_vm(vm_id) |
| 124 | + |
| 125 | + |
| 126 | +def make_wait_options(max_wait_seconds: int = None, max_object_updates: int = None) -> \ |
| 127 | + vmodl.query.PropertyCollector.WaitOptions: |
| 128 | + """ |
| 129 | + Creates property collector wait options needed for WaitForUpdatesEx API. |
| 130 | + """ |
| 131 | + wait_opts = vmodl.query.PropertyCollector.WaitOptions() |
| 132 | + |
| 133 | + if max_object_updates is not None: |
| 134 | + wait_opts.maxObjectUpdates = max_object_updates |
| 135 | + |
| 136 | + if max_wait_seconds is not None: |
| 137 | + wait_opts.maxWaitSeconds = max_wait_seconds |
| 138 | + |
| 139 | + return wait_opts |
| 140 | + |
| 141 | + |
| 142 | +def create_view_filter(view: vim.view.View, |
| 143 | + prop_spec: vmodl.query.PropertyCollector.PropertySpec) -> \ |
| 144 | + vmodl.query.PropertyCollector.FilterSpec: |
| 145 | + """ |
| 146 | + Create a property collector filter spec based on a view object and a set of |
| 147 | + properties the caller wants to monitor. |
| 148 | + """ |
| 149 | + traversal_spec = vmodl.query.PropertyCollector.TraversalSpec() |
| 150 | + traversal_spec.name = "traverseEntities" |
| 151 | + traversal_spec.path = "view" |
| 152 | + traversal_spec.skip = False |
| 153 | + traversal_spec.type = vim.view.ContainerView |
| 154 | + |
| 155 | + objectSpec = vmodl.query.PropertyCollector.ObjectSpec() |
| 156 | + objectSpec.obj = view |
| 157 | + objectSpec.skip = True |
| 158 | + objectSpec.selectSet = [traversal_spec] |
| 159 | + |
| 160 | + filter_spec = vmodl.query.PropertyCollector.FilterSpec() |
| 161 | + filter_spec.propSet = [prop_spec] |
| 162 | + filter_spec.objectSet = [objectSpec] |
| 163 | + |
| 164 | + return filter_spec |
| 165 | + |
| 166 | + |
| 167 | +class VmMacChangeDetector: |
| 168 | + """ Detects changes in the MAC addresses of VMs from PropertyCollector updates. """ |
| 169 | + def __init__(self, si: vim.ServiceInstance, listener: VmMacChangeListener, |
| 170 | + max_wait_seconds: int = 10, max_object_updates: int = 100) -> None: |
| 171 | + """ |
| 172 | + Create a new VM Mac Change Detector |
| 173 | + si: The Service Instance |
| 174 | + listener: The listener to notify of changes |
| 175 | + """ |
| 176 | + self.si = si |
| 177 | + self.listener = listener |
| 178 | + self.max_wait_seconds = max_wait_seconds |
| 179 | + self.max_object_updates = max_object_updates |
| 180 | + self.version = "" |
| 181 | + self.pc = None |
| 182 | + self.view = None |
| 183 | + self.filter = None |
| 184 | + |
| 185 | + def monitor(self, seconds: int): |
| 186 | + """ |
| 187 | + Monitor for changes in the mac addresses of VMs. |
| 188 | + seconds: number of seconds to monitor changes. 0 monitors indefinitely |
| 189 | + """ |
| 190 | + if not self.pc: |
| 191 | + self._init_property_collector() |
| 192 | + |
| 193 | + wait_opts = make_wait_options(self.max_wait_seconds, self.max_object_updates) |
| 194 | + |
| 195 | + start = time.time() |
| 196 | + while seconds == 0 or time.time() - start < seconds: |
| 197 | + res = self.pc.WaitForUpdatesEx(self.version, wait_opts) |
| 198 | + if res is None: |
| 199 | + continue |
| 200 | + self.version = res.version |
| 201 | + for filter_set in res.filterSet: |
| 202 | + if filter_set.filter == self.filter: |
| 203 | + self._process_updates(filter_set.objectSet) |
| 204 | + |
| 205 | + def close(self): |
| 206 | + """ Close the active objects """ |
| 207 | + self.filter.DestroyPropertyFilter() |
| 208 | + self.view.DestroyView() |
| 209 | + self.pc.DestroyPropertyCollector() |
| 210 | + self.filter = None |
| 211 | + self.view = None |
| 212 | + self.pc = None |
| 213 | + |
| 214 | + def _init_property_collector(self): |
| 215 | + """ Initialise the PropertyCollector """ |
| 216 | + self.pc = self.si.content.propertyCollector.CreatePropertyCollector() |
| 217 | + view_mgr = self.si.content.viewManager |
| 218 | + root_folder = self.si.content.rootFolder |
| 219 | + self.view = view_mgr.CreateContainerView(root_folder, [vim.VirtualMachine], True) |
| 220 | + |
| 221 | + prop_spec = vmodl.query.PropertyCollector.PropertySpec() |
| 222 | + prop_spec.type = vim.VirtualMachine |
| 223 | + prop_spec.pathSet = [NAME_PROP_PATH, GUEST_NET_PROP_PATH, DEVICES_PROP_PATH] |
| 224 | + |
| 225 | + filter_spec = create_view_filter(self.view, prop_spec) |
| 226 | + self.filter = self.pc.CreateFilter(filter_spec, False) |
| 227 | + self.version = "" |
| 228 | + |
| 229 | + def _process_updates(self, objects: list[vmodl.query.PropertyCollector.ObjectUpdate]): |
| 230 | + for obj_update in objects: |
| 231 | + # pylint: disable=W0212 |
| 232 | + mo_id = obj_update.obj._GetMoId() |
| 233 | + if obj_update.kind == "leave": |
| 234 | + self.listener.remove_vm(mo_id) |
| 235 | + continue |
| 236 | + # 'enter' or 'modify' |
| 237 | + name = None |
| 238 | + vnic = None |
| 239 | + guest_net = None |
| 240 | + for change in obj_update.changeSet: |
| 241 | + if change.name == NAME_PROP_PATH: |
| 242 | + if change.op != "assign": |
| 243 | + print(f"WARN: Unexpected name change in {mo_id} \ |
| 244 | + {obj_update.obj.name}: {change.op}") |
| 245 | + continue |
| 246 | + name = change.val |
| 247 | + if change.name == GUEST_NET_PROP_PATH: |
| 248 | + if change.op != "assign": |
| 249 | + print(f"WARN: Unexpected net change in {mo_id} \ |
| 250 | + {obj_update.obj.name}: {change.op}") |
| 251 | + continue |
| 252 | + guest_net = self._get_guest_addresses(change.val) |
| 253 | + if change.name == DEVICES_PROP_PATH: |
| 254 | + if change.op != "assign": |
| 255 | + print(f"WARN: Unexpected device change in {mo_id} \ |
| 256 | + {obj_update.obj.name}: {change.op}") |
| 257 | + continue |
| 258 | + vnic = self._get_vnic_addresses(change.val) |
| 259 | + self.listener.update_vm(mo_id, name, vnic, guest_net) |
| 260 | + |
| 261 | + def _get_vnic_addresses(self, devices): |
| 262 | + vnic = {} |
| 263 | + for device in devices: |
| 264 | + if isinstance(device, vim.vm.device.VirtualEthernetCard) and \ |
| 265 | + device.key and device.macAddress: |
| 266 | + vnic[device.key] = device.macAddress |
| 267 | + return vnic |
| 268 | + |
| 269 | + def _get_guest_addresses(self, nics): |
| 270 | + guest_net = {} |
| 271 | + for nic in nics: |
| 272 | + if isinstance(nic, vim.vm.GuestInfo.NicInfo) and nic.macAddress: |
| 273 | + guest_net[nic.macAddress] = [ip.ipAddress for ip in nic.ipConfig.ipAddress] \ |
| 274 | + if nic.ipConfig else [] |
| 275 | + return guest_net |
| 276 | + |
| 277 | + def __enter__(self): |
| 278 | + return self |
| 279 | + |
| 280 | + def __exit__(self, exc_type, exc_value, traceback): |
| 281 | + self.close() |
| 282 | + |
| 283 | + |
| 284 | +def main(): |
| 285 | + """ |
| 286 | + Sample monitoring for changes in the MAC addresses of VMs. |
| 287 | + """ |
| 288 | + parser = cli.Parser() |
| 289 | + parser.add_optional_arguments(cli.Argument.MINUTES) |
| 290 | + parser.add_custom_argument('--no_filter', |
| 291 | + action="store_true", |
| 292 | + default=False, |
| 293 | + help='Remove the filtering cache.') |
| 294 | + args = parser.get_args() |
| 295 | + si = service_instance.connect(args) |
| 296 | + wait_seconds = int(args.minutes) * 60 if args.minutes else 60 |
| 297 | + try: |
| 298 | + printer = VmMacChangePrinter() |
| 299 | + |
| 300 | + listener = printer if args.no_filter else VmMacCache(printer) |
| 301 | + |
| 302 | + with VmMacChangeDetector(si, listener) as detector: |
| 303 | + detector.monitor(wait_seconds) |
| 304 | + finally: |
| 305 | + Disconnect(si) |
| 306 | + |
| 307 | + |
| 308 | +if __name__ == "__main__": |
| 309 | + main() |
0 commit comments