Private/Checks/Esx/Test-VcfEsxImageProfile.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================
function Test-VcfEsxImageProfile {

    <#
        .SYNOPSIS
        Reports the ESX image/build string for every host across all vCenters managed by SDDC Manager.

        .DESCRIPTION
        Retrieves host inventory for each vCenter domain using Get-VcfCheckVMHostInventory and extracts
        the full ESX product/build string from each host's Summary.Config.Product.FullName API property,
        plus the applied image profile name, vendor, and acceptance level via
        Get-VcfCheckEsxImageProfileForHost (esxcli software profile get). The Summary.Config.Product
        build string is already available from inventory without an esxcli round trip, so when every
        host in a cluster reports the identical build string, the esxcli lookup runs against a single
        representative host for that cluster rather than every host - the same image profile applies to
        all of them in that case. Only a cluster with divergent build strings falls back to querying
        esxcli per host. The esxcli lookup is best-effort: a failure leaves ImageProfileName/Vendor/
        AcceptanceLevel blank for the affected host(s) rather than failing the domain.

        Also queries each cluster's vLCM desired software specification via
        Get-VcfCheckClusterLcmSoftware (once per cluster, not per host) and adds the vendor Add-on,
        Components, and Firmware & Drivers Add-on (hardware support package) - the same breakdown shown
        on the vSphere Client's Cluster > Updates > Image page. Build string/image profile alone cannot
        distinguish two clusters running the identical base image with a different vendor Add-on or
        Firmware & Drivers Add-on applied. AddOn/Components/FirmwareAndDriversAddOn do not apply to a
        vLCM baseline-managed cluster, per Get-VcfCheckClusterImageBasedByMoRef, and report
        'N/A' for those. This lookup is also best-effort: a failure on an image-managed cluster leaves
        AddOn/Components/FirmwareAndDriversAddOn as 'Unavailable' rather than failing the domain, and a
        field left unconfigured on the cluster (e.g. no vendor Add-on) reports 'None'.

        Informational only: builds a tabular breakdown containing Cluster, Hostname, Image,
        ImageProfileName, Vendor, AcceptanceLevel, AddOn, Components, and FirmwareAndDriversAddOn (sorted
        by Cluster, then Hostname). Reports 'Pass' per vCenter domain as long as host details are
        collected. Returns an 'Error' status for a domain if no hosts are found, if connection to
        vCenter fails, or if inventory retrieval fails.

        Iterates through all vCenters managed by SDDC Manager and delegates output generation to
        New-VcfCheckPerDomainResults to return results grouped per vCenter domain.

        Long-running on a domain with many clusters (the esxcli and vLCM software lookups per
        cluster dominate runtime): reports its own progress via Write-VcfCheckSubProgress as each
        cluster is processed.

        .PARAMETER Context
        The VcfCheck.Context object. Must already be connected to SDDC Manager.

        .PARAMETER DisplayName
        Optional friendly name for the check, used when returning top-level error results.

        .OUTPUTS
        [PSObject[]] Array of per-domain result objects generated by New-VcfCheckPerDomainResults.
    #>


    [CmdletBinding()]
    [OutputType([PSObject[]])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $false)] [String]$DisplayName = ''
    )

    $startedAt = Get-Date
    $checkId = 'esx_image_profile'
    $catalogEntry = (Get-VcfCheckCatalog)[$checkId]
    $displayName = if ([String]::IsNullOrEmpty($DisplayName)) { $catalogEntry.displayName } else { $DisplayName }

    try {
        $vcenterFqdns = Get-VcfCheckAllVCenterFqdns -Context $Context
    } catch {
        return New-VcfCheckResult -CheckId $checkId -Status Error `
            -Exception $_.Exception.Message -StartedAt $startedAt -CompletedAt (Get-Date) -DisplayName $displayName
    }

    $isImageBasedByMoRef = try {
        Get-VcfCheckClusterImageBasedByMoRef
    } catch {
        @{}
    }

    $outcomes = foreach ($vcenterFqdn in $vcenterFqdns) {
        $iterationStartedAt = Get-Date
        $outcome = try {
            Connect-VcfCheckVCenter -Context $Context -Fqdn $vcenterFqdn
            $hosts = @(Get-VcfCheckVMHostInventory -Server $vcenterFqdn)

            if ($hosts.Count -eq 0) {
                [PSCustomObject]@{ VCenterFqdn = $vcenterFqdn; Status = 'Error'; Detail = 'No ESX hosts found.'; Rows = @() }
            } else {
                $hostsByCluster = @($hosts | ForEach-Object {
                    [PSCustomObject]@{
                        Cluster   = $_.Parent.Name
                        ClusterId = $_.Parent.ExtensionData.MoRef.Value
                        Hostname  = $_.Name
                        Image     = $_.ExtensionData.Summary.Config.Product.FullName
                        VMHost    = $_
                    }
                } | Group-Object -Property Cluster)

                $rows = @()
                $clusterIndex = 0
                foreach ($clusterGroup in $hostsByCluster) {
                    $clusterIndex++
                    Write-VcfCheckSubProgress -Context $Context -Current $clusterIndex -Total $hostsByCluster.Count -Label $clusterGroup.Name -Unit 'clusters'
                    $clusterName = $clusterGroup.Name
                    $hostsInCluster = @($clusterGroup.Group)
                    $uniqueImages = @($hostsInCluster | Select-Object -Property Image -Unique)

                    if ($uniqueImages.Count -eq 1) {
                        $imageProfile = try {
                            Get-VcfCheckEsxImageProfileForHost -VMHost $hostsInCluster[0].VMHost
                        } catch {
                            $null
                        }
                        foreach ($hostRecord in $hostsInCluster) {
                            $hostRecord | Add-Member -NotePropertyName ImageProfileName -NotePropertyValue $imageProfile.Name -Force
                            $hostRecord | Add-Member -NotePropertyName Vendor -NotePropertyValue $imageProfile.Vendor -Force
                            $hostRecord | Add-Member -NotePropertyName AcceptanceLevel -NotePropertyValue $imageProfile.AcceptanceLevel -Force
                        }
                    } else {
                        foreach ($hostRecord in $hostsInCluster) {
                            $imageProfile = try {
                                Get-VcfCheckEsxImageProfileForHost -VMHost $hostRecord.VMHost
                            } catch {
                                $null
                            }
                            $hostRecord | Add-Member -NotePropertyName ImageProfileName -NotePropertyValue $imageProfile.Name -Force
                            $hostRecord | Add-Member -NotePropertyName Vendor -NotePropertyValue $imageProfile.Vendor -Force
                            $hostRecord | Add-Member -NotePropertyName AcceptanceLevel -NotePropertyValue $imageProfile.AcceptanceLevel -Force
                        }
                    }

                    $uniqueProfiles = @($hostsInCluster | Select-Object -Property Image, ImageProfileName, Vendor, AcceptanceLevel -Unique)

                    $clusterId = $hostsInCluster[0].ClusterId
                    $isImageBased = if ($clusterId -and $isImageBasedByMoRef.ContainsKey($clusterId)) { $isImageBasedByMoRef[$clusterId] } else { $true }

                    if ($isImageBased) {
                        $clusterSoftware = try {
                            Get-VcfCheckClusterLcmSoftware -ClusterId $clusterId -Server $vcenterFqdn
                        } catch {
                            Write-LogMessage -Type WARNING -Message "vLCM software lookup for cluster `"$clusterName`" failed - AddOn/Components/FirmwareAndDriversAddOn will show `"Unavailable`" for it: $($_.Exception.Message)"
                            $null
                        }
                        $lcmSummary = ConvertTo-VcfCheckLcmSoftwareSummary -Software $clusterSoftware
                    } else {
                        $lcmSummary = [PSCustomObject]@{ AddOn = 'N/A'; Components = @('N/A'); FirmwareAndDriversAddOn = @('N/A') }
                    }

                    if ($uniqueProfiles.Count -eq 1) {
                        $commonProfile = $uniqueProfiles[0]
                        $hostCount = $hostsInCluster.Count
                        $rows += [PSCustomObject]@{
                            Cluster                 = $clusterName
                            Hostname                = "All $hostCount host$(if ($hostCount -ne 1) { 's' }) in the cluster have the identical image"
                            Image                   = $commonProfile.Image
                            ImageProfileName        = $commonProfile.ImageProfileName
                            Vendor                  = $commonProfile.Vendor
                            AcceptanceLevel         = $commonProfile.AcceptanceLevel
                            AddOn                   = $lcmSummary.AddOn
                            Components              = $lcmSummary.Components
                            FirmwareAndDriversAddOn = $lcmSummary.FirmwareAndDriversAddOn
                        }
                    } else {
                        $rows += @($hostsInCluster | Sort-Object -Property Hostname | ForEach-Object {
                            [PSCustomObject]@{
                                Cluster                 = $_.Cluster
                                Hostname                = $_.Hostname
                                Image                   = $_.Image
                                ImageProfileName        = $_.ImageProfileName
                                Vendor                  = $_.Vendor
                                AcceptanceLevel         = $_.AcceptanceLevel
                                AddOn                   = $lcmSummary.AddOn
                                Components              = $lcmSummary.Components
                                FirmwareAndDriversAddOn = $lcmSummary.FirmwareAndDriversAddOn
                            }
                        })
                    }
                }
                $rows = @($rows | Sort-Object -Property Cluster)

                [PSCustomObject]@{ VCenterFqdn = $vcenterFqdn; Status = 'Pass'; Detail = $null; Rows = $rows }
            }
        } catch {
            [PSCustomObject]@{ VCenterFqdn = $vcenterFqdn; Status = 'Error'; Detail = $_.Exception.Message; Rows = @() }
        }
        $outcome | Add-Member -NotePropertyName StartedAt -NotePropertyValue $iterationStartedAt -Force
        $outcome | Add-Member -NotePropertyName CompletedAt -NotePropertyValue (Get-Date) -Force
        $outcome
    }

    return New-VcfCheckPerDomainResults -Context $Context -PerVCenterOutcome $outcomes -CheckId $checkId `
        -StartedAt $startedAt
}
function ConvertTo-VcfCheckLcmSoftwareSummary {

    <#
        .SYNOPSIS
        Formats a cluster's vLCM software specification into report-ready AddOn/Components/
        FirmwareAndDriversAddOn strings.

        .DESCRIPTION
        AddOn, Components, and HardwareSupport.Packages can each be null or empty when not
        configured on the cluster, and a HardwareSupport package entry can have blank
        Pkg/Version fields - all handled here so callers get a stable 'None' rather than a blank cell.

        Components and FirmwareAndDriversAddOn are returned as string arrays rather than a single
        joined string so Format-VcfCheckHtmlRowsTable can render multiple entries as a list instead
        of one run-on semicolon-separated cell. AddOn is returned as a single joined string - a cluster
        normally has at most one vendor Add-on, but Software.AddOn.Details can come back with duplicate
        entries for that same Add-on, so entries are de-duplicated before joining.

        .PARAMETER Software
        The EsxSettingsSoftwareInfo object from Get-VcfCheckClusterLcmSoftware, or $null if the
        lookup failed.

        .OUTPUTS
        [PSObject] with AddOn (String), Components (String[]), FirmwareAndDriversAddOn (String[]) -
        each 'Unavailable'/@('Unavailable') if -Software is $null, or 'None'/@('None') if the cluster
        has nothing configured for that field.
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $false)] [PSObject]$Software
    )

    if ($null -eq $Software) {
        return [PSCustomObject]@{ AddOn = 'Unavailable'; Components = @('Unavailable'); FirmwareAndDriversAddOn = @('Unavailable') }
    }

    $addOnEntries = @($Software.AddOn.Details | ForEach-Object {
        $entry = "$($_.DisplayName) $($_.DisplayVersion)".Trim()
        if (-not [String]::IsNullOrWhiteSpace($entry)) { $entry }
    } | Select-Object -Unique)
    $addOn = if ($addOnEntries.Count -gt 0) { $addOnEntries -join '; ' } else { 'None' }

    $componentEntries = @($Software.Components.Values | ForEach-Object {
        $entry = "$($_.Details.DisplayName) $($_.Details.DisplayVersion)".Trim()
        if (-not [String]::IsNullOrWhiteSpace($entry)) { $entry }
    })
    $components = if ($componentEntries.Count -gt 0) { $componentEntries } else { @('None') }

    $hardwareSupportEntries = @($Software.HardwareSupport.Packages.Values | ForEach-Object {
        $entry = "$($_.Pkg) $($_.Version)".Trim()
        if (-not [String]::IsNullOrWhiteSpace($entry)) { $entry }
    })
    $firmwareAndDriversAddOn = if ($hardwareSupportEntries.Count -gt 0) { $hardwareSupportEntries } else { @('None') }

    return [PSCustomObject]@{
        AddOn                   = $addOn
        Components              = $components
        FirmwareAndDriversAddOn = $firmwareAndDriversAddOn
    }
}