<# .SYNOPSIS Discover VMware vSphere networking with PowerCLI and export a Switchblade-Evaluator-compatible inventory JSON. No Python, no pyVmomi — Switchblade-Collector is the only VMware discovery path. .DESCRIPTION PowerCLI's `Get-View` talks to the exact same vSphere API objects a Python pyVmomi client would (this script is a PowerShell port of logic that was originally written in Python and validated against a live vCenter, then ported here so vSphere admins never have to run an unfamiliar binary against vCenter — see README.md). It walks those objects the same way the original did: - `config.virtualNicManagerInfo` for VMkernel service tagging (Management/vMotion/vSAN/Fault Tolerance/Provisioning/Replication) - `HostStorageSystem.QueryBoundVnics` for iSCSI, which isn't a virtualNicManagerInfo nicType at all — the software iSCSI HBA reports its bound vmknic(s) separately - Per-host aggregation of same-named standard vSwitch portgroups, for the cross-host VLAN/MTU consistency check ...and produces JSON that validates directly against `switchblade_evaluator.models.ClusterInventory` — no conversion step. Feed the output straight into Switchblade-Evaluator: switchblade-evaluator switchblade-inventory.json This script is entirely read-only: every call below is a property read or `QueryBoundVnics` (also read-only), never a reconfigure. The vCenter **Read-only** role, assigned at vCenter root or the target Datacenter, is sufficient — see README.md's "vSphere permissions required" section, and docs/HOWTO-export-inventory.md for a walkthrough aimed at the vSphere admin running this script. Known scope limits (deliberate, not bugs): - NFS VMkernel traffic is never auto-tagged. There is no per-vmk "this carries NFS" binding anywhere in the vSphere API — NFS datastore traffic is routed by whatever the host's routing table says based on the datastore server's IP, not an explicit binding. - PVLAN, Fault Tolerance, and vSAN networks are discovered like any other network; Switchblade-Evaluator's classifier (not this script) is what routes them to "manual review". - NSX-backed / opaque networks are discovered but not specially translated — they'll show up with real names/VLANs, but nothing here understands NSX segments as such. .PARAMETER OutputPath Path to write the JSON inventory. Defaults to switchblade-inventory.json in the current directory. This script always writes JSON (not YAML) — Evaluator's own load/save can convert if you want YAML. .EXAMPLE Connect-VIServer vcenter.example.com -Credential (Get-Credential) ./Switchblade-Collector.ps1 -OutputPath switchblade-inventory.json Disconnect-VIServer -Confirm:$false switchblade-evaluator switchblade-inventory.json #> [CmdletBinding()] param( [string]$OutputPath = "switchblade-inventory.json" ) if (-not $global:DefaultVIServer -or -not $global:DefaultVIServer.IsConnected) { throw "Not connected to a vCenter/ESXi host. Run Connect-VIServer first." } # --------------------------------------------------------------------------- # nicType (the raw string vSphere's virtualNicManagerInfo API uses) -> the # VMkernelService enum value switchblade_evaluator.models expects. Keep this # map in lockstep with that enum — if a value here doesn't match a real # VMkernelService member, Evaluator's pydantic validation will reject the # JSON this script produces outright. # # "_iscsi_bound_vnic" isn't a real vSphere nicType at all — it's a synthetic # marker this script assigns itself (see Get-HostServiceMap below) so iSCSI # flows through the exact same "device -> service" lookup as every other # service, instead of needing a separate code path. # --------------------------------------------------------------------------- $NicTypeToService = @{ "management" = "management" "vmotion" = "vmotion" "vsan" = "vsan" "faultToleranceLogging" = "fault_tolerance" "vSphereProvisioning" = "provisioning" "vSphereReplication" = "replication" "vSphereReplicationNFC" = "replication" "_iscsi_bound_vnic" = "iscsi" } function Convert-VlanConfig { # Translates one portgroup's raw VLAN configuration into Switchblade's # Vlan shape: @{ mode = "access"|"trunk"|"none"; ... }. The input shape # differs by switch type, which is why this checks several possible # forms rather than assuming one: # - $null -> no VLAN config at all (untagged) # - a bare [int] -> a vSS portgroup's raw VlanId (vSS's # HostPortGroupSpec has no richer vlan # *object* the way vDS does — the main # loop below passes the int directly) # - has a .PvlanId property -> a Private VLAN (vDS only) # - .VlanId is an array -> a trunk (vDS only); each element is a # NumericRange(Start, End) # - .VlanId is a plain int -> a plain access VLAN (vDS) # Returns @{ vlan = ; pvlan = }. param($VlanCfg) if ($null -eq $VlanCfg) { return @{ vlan = [ordered]@{ mode = "none" }; pvlan = $false } } # Bare-int case MUST be checked before touching .VlanId/.PvlanId below: # PowerShell doesn't error on accessing a nonexistent property, it # silently returns $null — so without this check, a vSS portgroup's # int VLAN ID would fall through to "$VlanCfg.VlanId is $null" and get # reported as untagged no matter its real value. (This was caught by # actually exercising the function against a bare int, not just # reading the code — worth remembering if this function changes again.) if ($VlanCfg -is [int]) { if ($VlanCfg -eq 0) { return @{ vlan = [ordered]@{ mode = "none" }; pvlan = $false } } return @{ vlan = [ordered]@{ mode = "access"; id = [int]$VlanCfg }; pvlan = $false } } # PvlanSpec check next: some spec shapes could plausibly carry both a # PvlanId and something resembling VlanId, so decide PVLAN-ness before # even looking at VlanId. if ($VlanCfg.PSObject.Properties.Match("PvlanId").Count -gt 0) { return @{ vlan = [ordered]@{ mode = "access"; id = [int]$VlanCfg.PvlanId }; pvlan = $true } } $vlanId = $VlanCfg.VlanId # A vDS trunk portgroup's VlanId is an array of NumericRange(Start, End) # — e.g. one entry per contiguous VLAN range in the trunk. if ($vlanId -is [array]) { $ranges = @() foreach ($r in $vlanId) { if ($r.Start -eq $r.End) { $ranges += "$($r.Start)" } else { $ranges += "$($r.Start)-$($r.End)" } } return @{ vlan = [ordered]@{ mode = "trunk"; ranges = @($ranges) }; pvlan = $false } } if ($null -eq $vlanId -or [int]$vlanId -eq 0) { return @{ vlan = [ordered]@{ mode = "none" }; pvlan = $false } } # vDS plain access VLAN. return @{ vlan = [ordered]@{ mode = "access"; id = [int]$vlanId }; pvlan = $false } } function Get-HostServiceMap { # Builds this one host's "device (e.g. vmk0) -> list of nicType # strings" map — the same lookup table every network below needs to # answer "which VMkernel service(s), if any, does this vmknic carry?" # Computed once per host up front (see $serviceMaps below) rather than # per-network, since the same host's vmknics get consulted by every # portgroup that host participates in. param($HostView) $deviceToTypes = @{} # Part 1: every "normal" VMkernel service (Management, vMotion, vSAN, # Fault Tolerance, Provisioning, Replication) is declared in # virtualNicManagerInfo.NetConfig, one entry per nicType, each listing # the vnic "keys" bound to that service. $netConfigs = $HostView.Config.VirtualNicManagerInfo.NetConfig $vnics = $HostView.Config.Network.Vnic $keyToDevice = @{} foreach ($v in $vnics) { $keyToDevice[$v.Key] = $v.Device } foreach ($cfg in $netConfigs) { # SelectedVnic entries are "{nicType}.{vnic.key}" strings (e.g. # "management.key-vim.host.VirtualNic-vmk0"), not bare vnic keys — # and a vnic key itself contains dots, so the nicType prefix has to # be stripped explicitly rather than guessed at from string shape. $prefix = "$($cfg.NicType)." foreach ($selected in $cfg.SelectedVnic) { $vnicKey = if ($selected.StartsWith($prefix)) { $selected.Substring($prefix.Length) } else { $selected } $device = $keyToDevice[$vnicKey] if (-not $device) { continue } if (-not $deviceToTypes.ContainsKey($device)) { $deviceToTypes[$device] = @() } $deviceToTypes[$device] += $cfg.NicType } } # Part 2: iSCSI isn't a virtualNicManagerInfo nicType at all, but the # software iSCSI HBA (driver == "iscsi_vmk") has its own explicit, # queryable port-binding relationship via QueryBoundVnics(). Fold its # result into the same $deviceToTypes map under the synthetic # "_iscsi_bound_vnic" marker, so iSCSI flows through the identical # nicType -> VMkernelService lookup as everything above rather than # needing a parallel code path downstream. try { $storageSystem = Get-View $HostView.ConfigManager.StorageSystem -ErrorAction Stop $hbas = $HostView.Config.StorageDevice.HostBusAdapter foreach ($hba in $hbas) { if ($hba.Driver -ne "iscsi_vmk") { continue } $bound = $storageSystem.QueryBoundVnics($hba.Device) foreach ($b in $bound) { if ($b.VnicDevice) { if (-not $deviceToTypes.ContainsKey($b.VnicDevice)) { $deviceToTypes[$b.VnicDevice] = @() } $deviceToTypes[$b.VnicDevice] += "_iscsi_bound_vnic" } } } } catch { # No software iSCSI HBA on this host (or the query otherwise # failed) — not fatal, this host just contributes no iSCSI tags. Write-Verbose "Skipping iSCSI bound-vnic lookup for host $($HostView.Name): $_" } return $deviceToTypes } function Get-ServicesForNicTypes { # Maps a list of raw vSphere nicType strings (as collected per-vmknic # in the main loop below) to Switchblade's VMkernelService enum values, # silently dropping any type with no entry in $NicTypeToService. param($NicTypes) $services = @() foreach ($t in $NicTypes) { if ($NicTypeToService.ContainsKey($t)) { $services += $NicTypeToService[$t] } } return @($services) } # =========================================================================== # Main: discover, then build one Switchblade Network entry per portgroup. # # 1. Fetch every host, DVS, and network object once via Get-View (below). # 2. Pre-compute each host's device -> nicType map (Get-HostServiceMap), # since every portgroup that host participates in needs to consult it. # 3. Walk every network object once. vim.Network's container view already # returns one object per distributed portgroup (a # DistributedVirtualPortgroup) AND one object per *unique* standard # vSwitch portgroup name, aggregated across every host that has it — # vCenter does this merging itself, this script doesn't have to. # Each iteration below branches on which of those two shapes it is. # 4. Assemble the results into one ClusterInventory-shaped JSON and write # it to -OutputPath. # =========================================================================== Write-Host "Reading vCenter/host inventory via PowerCLI (read-only)..." -ForegroundColor Cyan $si = Get-View ServiceInstance $about = $si.Content.About $hostViews = @(Get-View -ViewType HostSystem) $dvsViews = @(Get-View -ViewType DistributedVirtualSwitch) $netViews = @(Get-View -ViewType Network) # Lookup tables keyed by managed-object-reference value, so the main loop # below can resolve a host/DVS reference to its full object in O(1) instead # of scanning $hostViews/$dvsViews on every network. $hostByMoref = @{} foreach ($h in $hostViews) { $hostByMoref[$h.MoRef.Value] = $h } $dvsByMoref = @{} foreach ($d in $dvsViews) { $dvsByMoref[$d.MoRef.Value] = $d } # One device->nicType map per host, computed once up front (see # Get-HostServiceMap's comment for why) rather than recomputed for every # network that host happens to carry. $serviceMaps = @{} foreach ($h in $hostViews) { $serviceMaps[$h.MoRef.Value] = Get-HostServiceMap $h } $networks = @() foreach ($net in $netViews) { # A DistributedVirtualPortgroup's managed-object-reference Type string # distinguishes it from a plain vim.Network (standard vSwitch # portgroup) — this is the branch point for the rest of the loop body. $isVds = $net.MoRef.Type -eq "DistributedVirtualPortgroup" if ($isVds) { # ------------------------------------------------------------- # Distributed portgroup: VLAN/MTU config lives on the switch-wide # DVS object plus this portgroup's own DefaultPortConfig, not # per-host — there's no cross-host consistency concept to compute # here the way there is for vSS (a vDS portgroup's config is # inherently the same everywhere it's used). # ------------------------------------------------------------- $dvs = $dvsByMoref[$net.Config.DistributedVirtualSwitch.Value] if (-not $dvs) { continue } $conv = Convert-VlanConfig $net.Config.DefaultPortConfig.Vlan # VM consumer: how many VMs have a vNIC attached to this portgroup. $consumers = @() $vmCount = @($net.Vm).Count if ($vmCount -gt 0) { $consumers += [ordered]@{ kind = "vm"; count = $vmCount } } # VMkernel consumer: for every host that carries this portgroup, # find its VMkernel adapters that are actually attached to *this* # specific DV port (matched by PortgroupKey), then look up what # service(s) each of those adapters' device names map to via the # per-host service map built above. $nicTypes = @() foreach ($hh in @($net.Host)) { $hostView = $hostByMoref[$hh.Value] if (-not $hostView) { continue } $deviceTypes = $serviceMaps[$hostView.MoRef.Value] foreach ($vnic in @($hostView.Config.Network.Vnic)) { $dvp = $vnic.Spec.DistributedVirtualPort if ($dvp -and $dvp.PortgroupKey -eq $net.Key -and $deviceTypes.ContainsKey($vnic.Device)) { $nicTypes += $deviceTypes[$vnic.Device] } } } $services = Get-ServicesForNicTypes $nicTypes if ($services.Count -gt 0) { $consumers += [ordered]@{ kind = "vmkernel"; services = @($services) } } # A vDS auto-creates one "uplink" portgroup per switch to carry # physical uplink traffic — it never gets a VM/VMkernel attachment # by design, so flag it explicitly rather than letting it look like # a genuinely uncategorizable (zero-consumer) network downstream. $uplinkMoids = @($dvs.Config.UplinkPortgroup | ForEach-Object { $_.Value }) $isUplink = $uplinkMoids -contains $net.MoRef.Value $networks += [ordered]@{ name = $net.Name source = [ordered]@{ platform = "VMware"; switch = $dvs.Name; type = "vDS" } vlan = $conv.vlan mtu = $dvs.Config.MaxMtu pvlan = $conv.pvlan is_uplink = $isUplink consistency = $null # vSS-only concept; always null for vDS consumers = @($consumers) } } else { # ------------------------------------------------------------- # Standard vSwitch portgroup, already aggregated across every host # that carries it (vCenter's vim.Network container view merges # same-named standard portgroups cluster-wide on its own — this # script doesn't do that merging itself, it's just consuming the # already-merged object). Because vSS config is genuinely per-host # under the hood, this branch also computes the cross-host VLAN/MTU # consistency check vDS doesn't need. # ------------------------------------------------------------- $memberHosts = @($net.Host) if ($memberHosts.Count -eq 0) { continue } $vswitchName = $null $perHostVlan = [ordered]@{} # host name -> that host's raw vlanId int for this portgroup $perHostMtu = [ordered]@{} # host name -> that host's vSwitch MTU $nicTypes = @() foreach ($hh in $memberHosts) { $hostView = $hostByMoref[$hh.Value] if (-not $hostView) { continue } # Find this host's own copy of the portgroup config (by name) # and its parent vSwitch (by name) — vSS config genuinely lives # per-host, so both have to be looked up per host in the loop. $pg = @($hostView.Config.Network.Portgroup) | Where-Object { $_.Spec.Name -eq $net.Name } | Select-Object -First 1 if (-not $pg) { continue } $vswitchName = $pg.Spec.VswitchName $perHostVlan[$hostView.Name] = [int]$pg.Spec.VlanId $vsw = @($hostView.Config.Network.Vswitch) | Where-Object { $_.Name -eq $vswitchName } | Select-Object -First 1 if ($vsw) { $perHostMtu[$hostView.Name] = $vsw.Mtu } # Same per-vnic service lookup as the vDS branch, but matched # by plain portgroup name instead of a DV port key. $deviceTypes = $serviceMaps[$hostView.MoRef.Value] foreach ($vnic in @($hostView.Config.Network.Vnic)) { if ($vnic.Spec.Portgroup -eq $net.Name -and $deviceTypes.ContainsKey($vnic.Device)) { $nicTypes += $deviceTypes[$vnic.Device] } } } if (-not $vswitchName) { continue } # If every host that has this portgroup agrees on the VLAN ID, # convert that single value normally; otherwise there's no one # authoritative VLAN to report, so fall back to "none" and instead # record the disagreement itself as drift below. Same logic for MTU. $vlanIds = @($perHostVlan.Values | Select-Object -Unique) $conv = if ($vlanIds.Count -eq 1) { Convert-VlanConfig $vlanIds[0] } else { Convert-VlanConfig $null } $drift = @() if ($vlanIds.Count -gt 1) { $drift += "VLAN ID mismatch across hosts: $($perHostVlan | ConvertTo-Json -Compress)" } $mtuValues = @($perHostMtu.Values | Select-Object -Unique) $mtu = if ($mtuValues.Count -eq 1) { $mtuValues[0] } else { $null } if ($mtuValues.Count -gt 1) { $drift += "MTU mismatch across hosts: $($perHostMtu | ConvertTo-Json -Compress)" } $consumers = @() $vmCount = @($net.Vm).Count if ($vmCount -gt 0) { $consumers += [ordered]@{ kind = "vm"; count = $vmCount } } $services = Get-ServicesForNicTypes $nicTypes if ($services.Count -gt 0) { $consumers += [ordered]@{ kind = "vmkernel"; services = @($services) } } $networks += [ordered]@{ name = $net.Name source = [ordered]@{ platform = "VMware"; switch = $vswitchName; type = "vSS" } vlan = $conv.vlan mtu = $mtu pvlan = $false # PVLAN is a vDS-only concept is_uplink = $false # vSS has no auto-created uplink portgroup concept consistency = [ordered]@{ hosts_present = $perHostVlan.Count # hosts that actually have this portgroup hosts_total = $hostViews.Count # hosts in the whole inventory, for context drift = @($drift) } consumers = @($consumers) } } } # Assemble the final ClusterInventory-shaped object and write it out. Field # names/nesting here must match switchblade_evaluator.models.ClusterInventory # exactly, since that's what parses this JSON on the other end with no # translation step in between. $inventory = [ordered]@{ vcenter = "$($global:DefaultVIServer.Name) ($($about.FullName))" hosts = $hostViews.Count distributed_switches = $dvsViews.Count networks = @($networks) } $inventory | ConvertTo-Json -Depth 12 | Out-File -FilePath $OutputPath -Encoding utf8 Write-Host "Inventory written to $OutputPath ($($networks.Count) networks, $($hostViews.Count) hosts)" -ForegroundColor Green Write-Host "Next: switchblade-evaluator $OutputPath" -ForegroundColor Green