Private/VirtualMachines.ps1

function Get-VirtualMachineHash {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereHypervisorVMInfo]$VirtualMachine
    )

    # Create a deterministic string representation by sorting properties
    # This ensures consistent hashing regardless of property order in JSON
    $sortedProperties = $VirtualMachine.PSObject.Properties | Sort-Object Name
    $hashInput = ""
    foreach ($prop in $sortedProperties) {
        $hashInput += "$($prop.Name):$($prop.Value);"
    }

    $hasher = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashInput)
        $hashBytes = $hasher.ComputeHash($bytes)
        return [BitConverter]::ToString($hashBytes) -replace '-', ''
    }
    finally {
        $hasher.Dispose()
    }
}

function Get-VirtualMachinesStoragePath {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )

    $storageFolder = Join-Path -Path (Join-Path -Path $Config.ScriptRootPath -ChildPath $script:STORAGE_FOLDER_NAME) -ChildPath $Config.EnvironmentConfig.Name
    return Join-Path -Path $storageFolder -ChildPath 'virtual_machines.json'
}

function Read-VirtualMachinesStorage {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )

    $vmStoragePath = Get-VirtualMachinesStoragePath -Config $Config
    $vmStorageData = @{}

    if (Test-Path -Path $vmStoragePath) {
        try {
            $storageContent = Get-Content -Path $vmStoragePath -Raw | ConvertFrom-Json -ErrorAction Stop
            $storageContent.PSObject.Properties | ForEach-Object {
                # Handle cases where lastUpdated might not be parsed correctly
                $lastUpdated = if($_.Value.lastUpdated -is [datetime]) { $_.Value.lastUpdated.ToUniversalTime().ToString('o') } else { [string]$_.Value.lastUpdated }

                $vmData = @{
                    hash = $_.Value.hash
                    lastUpdated = $lastUpdated
                }
                $vmStorageData[$_.Name] = $vmData
            }

            Write-CustomLog -Message "Loaded existing virtual machine storage from $vmStoragePath" -Severity 'DEBUG'
        }
        catch {
            Write-CustomLog -Message "Error reading virtual machine storage file: $($_.Exception.Message)" -Severity 'WARNING'
            throw "Error reading virtual machine storage file: $($_.Exception.Message)"
        }
    }

    return $vmStorageData
}


function Save-VirtualMachinesStorage {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$StorageData,

        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )
    try {
        $vmStoragePath = Get-VirtualMachinesStoragePath -Config $Config
        $StorageData | ConvertTo-Json -Compress | Set-Content -Path $vmStoragePath -Force -ErrorAction Stop
        Write-CustomLog -Message "Virtual machines storage file saved successfully at '$vmStoragePath'" -Severity 'INFO'
        return $true
    }
    catch {
        Write-CustomLog -Message "Error saving virtual machines storage file: $($_.Exception.Message)" -Severity 'ERROR'
        return $false
    }
}


function Test-VirtualMachineChanged {
    param(
        [Parameter(Mandatory = $true)]
        [object]$VirtualMachineHashDetails,

        [Parameter(Mandatory = $true)]
        [hashtable]$StorageData,

        [Parameter(Mandatory = $true)]
        [datetime]$Timestamp
    )

    $utcTimestamp = $Timestamp.ToUniversalTime()
    $storageId = $VirtualMachineHashDetails.storageId
    $vmHash =$VirtualMachineHashDetails.hash

    if (-not $storageId) {
        Write-CustomLog -Message "Skipping VM with empty storage id" -Severity 'WARNING'
        return $false
    }

    if ($StorageData.ContainsKey($storageId)) {
        $storedVmData = $StorageData[$storageId]

        if ([string]::IsNullOrEmpty($storedVmData.lastUpdated) -or [string]::IsNullOrEmpty($storedVmData.hash)) {
            Write-CustomLog -Message "VM '$storageId' found in storage but required properties are missing or empty. Treating as new VM." -Severity 'DEBUG'
            return $true
        }

        $vmLastUpdate = ConvertFrom-RfcUtcTimestamp -Value $storedVmData.lastUpdated
        $vmMaxAge = $utcTimestamp.AddDays(-1)

        if ($vmLastUpdate -lt $vmMaxAge) {
            Write-CustomLog -Message "Update required: VM '$storageId' last update was too long ago." -Severity 'DEBUG'
            return $true
        }

        if ($storedVmData.hash -ne $vmHash) {
            Write-CustomLog -Message "Storage entry '$storageId' has changed" -Severity 'DEBUG'
            return $true
        }
        else {
            Write-CustomLog -Message "Storage entry '$storageId' unchanged" -Severity 'DEBUG'
            return $false
        }
    }
    else {
        Write-CustomLog -Message "New storage entry found: '$storageId'" -Severity 'DEBUG'
        return $true
    }
}

function New-VirtualMachinesBatchPayload {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereHypervisorPayload]$Payload
    )

    $batchPayload = [VSphereHypervisorPayload]::new()
    $batchPayload.schema_version = $Payload.schema_version
    $batchPayload.source = $Payload.source
    $batchPayload.customer_environment = $Payload.customer_environment
    $batchPayload.version = $Payload.version

    return $batchPayload
}

function Get-VirtualMachinesDiff {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereHypervisorPayload]$Payload,

        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config,

        [Parameter(Mandatory = $true)]
        [datetime]$Timestamp,

        [Parameter(Mandatory = $false)]
        [int]$BatchSize = $script:VM_ENRICHMENT_BATCH_SIZE
    )

    try {
        $storageData = Read-VirtualMachinesStorage -Config $Config
    }
    catch {
        Write-CustomLog -Message "Change detection for virtual machines skipped. $($_.Exception.Message)" -Severity 'ERROR'
        return
    }

    $lastUpdated = $Timestamp.ToUniversalTime().ToString('o')
    $batches = [System.Collections.Generic.List[VSphereHypervisorPayload]]::new()
    $batchVmUpdates = [System.Collections.Generic.List[hashtable]]::new()

    $currentBatchPayload = New-VirtualMachinesBatchPayload -Payload $Payload
    $currentBatchDataItemsByHost = [ordered]@{}
    $currentBatchVmCount = 0
    $currentBatchUpdates = @{}

    foreach ($dataItem in @($Payload.data)) {
        foreach ($vm in @($dataItem.virtual_machines)) {
            if ($null -eq $vm -or [string]::IsNullOrWhiteSpace($vm.name)) {
                Write-CustomLog -Message "Skipping VM with null value or missing name in host '$($dataItem.host.name)'" -Severity 'WARNING'
                continue
            }

            $vmHashDetails = @{
                storageId = $vm.name
                hash = Get-VirtualMachineHash -VirtualMachine $vm
            }

            if (-not (Test-VirtualMachineChanged -VirtualMachineHashDetails $vmHashDetails -StorageData $storageData -Timestamp $Timestamp)) {
                continue
            }

            if ($currentBatchVmCount -ge $BatchSize) {
                $currentBatchPayload.data = @($currentBatchDataItemsByHost.Values)
                $batches.Add($currentBatchPayload)
                $batchVmUpdates.Add($currentBatchUpdates)

                Write-CustomLog -Message "Starting new virtual machine batch" -Severity 'DEBUG'

                $currentBatchPayload = New-VirtualMachinesBatchPayload -Payload $Payload
                $currentBatchDataItemsByHost = [ordered]@{}
                $currentBatchVmCount = 0
                $currentBatchUpdates = @{}
            }

            $hostName = $dataItem.host.name
            if (-not $currentBatchDataItemsByHost.Contains($hostName)) {
                Write-CustomLog -Message "Current batch does not contain host '$hostName'. Creating new data item." -Severity 'DEBUG'

                $newDataItem = @{
                    host = $dataItem.host
                    events = @()
                    virtual_machines = [System.Collections.Generic.List[VSphereHypervisorVMInfo]]::new()
                }
                $currentBatchDataItemsByHost[$hostName] = $newDataItem
            }

            $currentBatchDataItemsByHost[$hostName].virtual_machines.Add($vm)
            $currentBatchVmCount++

            if (-not [string]::IsNullOrWhiteSpace($vm.name)) {
                $currentBatchUpdates[$vm.name] = @{
                    hash        = $vmHashDetails.hash
                    lastUpdated = $lastUpdated
                }
            }
        }
    }

    if ($currentBatchVmCount -gt 0) {
        $currentBatchPayload.data = @($currentBatchDataItemsByHost.Values)
        $batches.Add($currentBatchPayload)
        $batchVmUpdates.Add($currentBatchUpdates)
    }

    Write-CustomLog -Message "Prepared $($batches.Count) virtual machine batches for sending" -Severity 'DEBUG'

    return [pscustomobject]@{
        Payloads       = $batches
        Storage        = $storageData
        BatchVmUpdates = $batchVmUpdates
    }
}



function Send-VirtualMachines {
    param(
        [Parameter(Mandatory = $true)]
        [VSphereHypervisorPayload]$Payload,

        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )

    try {
        # We could use precisely the time at which devices were fetched,
        # but using the time at which we are preparing to send should be sufficient.
        $timestamp = Get-Date
        $diff = Get-VirtualMachinesDiff -Payload $Payload -Config $Config -Timestamp $timestamp

        if ($null -eq $diff -or $diff.Payloads.Count -eq 0) {
            Write-CustomLog -Message "No virtual machine changes to send" -Severity 'INFO'
            return
        }

        $requestContext = Get-HypervisorApiRequestContext -Config $Config
        $storage = $diff.Storage
        $anySent = $false

        for ($i = 0; $i -lt $diff.Payloads.Count; $i++) {
            try {
                $batchJson = ConvertTo-HypervisorPayloadJson -Payload $diff.Payloads[$i]
                $response = Invoke-HypervisorApi -RequestContext $requestContext -PayloadJson $batchJson

                foreach ($key in $diff.BatchVmUpdates[$i].Keys) {
                    $storage[$key] = $diff.BatchVmUpdates[$i][$key]
                }
                $anySent = $true

                Write-CustomLog -Message "Successfully sent virtual machines batch. Response code: $($response.StatusCode)" -Severity 'INFO' -NoCache
            }
            catch {
                Write-CustomLog -Message "Failed to send virtual machines batch. Error=$($_.Exception.Message)" -Severity 'ERROR' -NoCache
            }
        }
    } catch {
        Write-CustomLog -Message "Virtual machines change detection and sending failed. Details: $($_.Exception.Message)" -Severity 'ERROR'
        return
    }

    if ($anySent) {
        Save-VirtualMachinesStorage -StorageData $storage -Config $Config | Out-Null
    }
}