Private/VirtualMachines.ps1

function Get-VirtualMachineHash {
    param(
        [Parameter(Mandatory = $true)]
        [NutanixHypervisorVMInfo]$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)]
        [NutanixConnectorConfiguration]$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)]
        [NutanixConnectorConfiguration]$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)]
        [NutanixConnectorConfiguration]$Config
    )

    $tempPath = $null

    try {
        $vmStoragePath = Get-VirtualMachinesStoragePath -Config $Config
        $json = $StorageData | ConvertTo-Json -Compress
        $tempPath = "$vmStoragePath.tmp-$([Guid]::NewGuid().ToString('N'))"

        Set-Content -Path $tempPath -Value $json -Encoding utf8 -ErrorAction Stop
        Move-Item -Path $tempPath -Destination $vmStoragePath -Force -ErrorAction Stop

        Write-CustomLog -Message "Virtual machines storage file saved successfully at '$vmStoragePath'" -Severity 'INFO'
        return $true
    }
    catch {
        if ($null -ne $tempPath) {
            Remove-Item -Path $tempPath -Force -ErrorAction SilentlyContinue
        }

        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]$VmMaxAge
    )

    $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

        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)]
        [NutanixHypervisorPayload]$Payload
    )

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

    return $batchPayload
}

function New-VirtualMachinesBatchState {
    param(
        [Parameter(Mandatory = $true)]
        [NutanixHypervisorPayload]$Payload
    )

    return [pscustomobject]@{
        Payload          = New-VirtualMachinesBatchPayload -Payload $Payload
        DataItemsByHost  = [ordered]@{}
        VmCount          = 0
        Updates          = @{}
        TotalVmCount     = 0
    }
}

function Complete-VirtualMachinesBatch {
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$BatchState,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[NutanixHypervisorPayload]]$Batches,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[hashtable]]$BatchVmUpdates,

        [Parameter(Mandatory = $true)]
        [NutanixHypervisorPayload]$Payload
    )

    if ($BatchState.VmCount -eq 0) {
        return
    }

    $BatchState.Payload.data = @($BatchState.DataItemsByHost.Values)
    $Batches.Add($BatchState.Payload)
    $BatchVmUpdates.Add($BatchState.Updates)
    $BatchState.Payload = New-VirtualMachinesBatchPayload -Payload $Payload
    $BatchState.DataItemsByHost = [ordered]@{}
    $BatchState.VmCount = 0
    $BatchState.Updates = @{}
}

function Add-VirtualMachineToBatch {
    param(
        [Parameter(Mandatory = $true)]
        [object]$DataItem,

        [Parameter(Mandatory = $true)]
        [NutanixHypervisorVMInfo]$VirtualMachine,

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

        [Parameter(Mandatory = $true)]
        [string]$LastUpdated,

        [Parameter(Mandatory = $true)]
        [int]$BatchSize,

        [Parameter(Mandatory = $true)]
        [pscustomobject]$BatchState,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[NutanixHypervisorPayload]]$Batches,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[hashtable]]$BatchVmUpdates,

        [Parameter(Mandatory = $true)]
        [NutanixHypervisorPayload]$Payload
    )

    if ($BatchState.VmCount -ge $BatchSize) {
        Complete-VirtualMachinesBatch -BatchState $BatchState -Batches $Batches -BatchVmUpdates $BatchVmUpdates -Payload $Payload
        Write-CustomLog -Message "Starting new virtual machine batch" -Severity 'DEBUG'
    }

    $hostName = $DataItem.host.name
    if (-not $BatchState.DataItemsByHost.Contains($hostName)) {
        Write-CustomLog -Message "Current batch does not contain host '$hostName'. Creating new data item." -Severity 'DEBUG'
        $BatchState.DataItemsByHost[$hostName] = @{
            host = $DataItem.host
            events = @()
            virtual_machines = [System.Collections.Generic.List[NutanixHypervisorVMInfo]]::new()
        }
    }

    $BatchState.DataItemsByHost[$hostName].virtual_machines.Add($VirtualMachine)
    $BatchState.VmCount++
    $BatchState.TotalVmCount++
    $BatchState.Updates[$VirtualMachine.name] = @{
        hash        = $VirtualMachineHashDetails.hash
        lastUpdated = $LastUpdated
    }
}

function Add-ChangedVirtualMachineToBatch {
    param(
        [Parameter(Mandatory = $true)]
        [object]$DataItem,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [NutanixHypervisorVMInfo]$VirtualMachine,

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

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

        [Parameter(Mandatory = $true)]
        [string]$LastUpdated,

        [Parameter(Mandatory = $true)]
        [int]$BatchSize,

        [Parameter(Mandatory = $true)]
        [pscustomobject]$BatchState,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[NutanixHypervisorPayload]]$Batches,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[hashtable]]$BatchVmUpdates,

        [Parameter(Mandatory = $true)]
        [NutanixHypervisorPayload]$Payload
    )

    if ($null -eq $VirtualMachine -or [string]::IsNullOrWhiteSpace($VirtualMachine.name)) {
        Write-CustomLog -Message "Skipping VM with null value or missing name in host '$($DataItem.host.name)'" -Severity 'WARNING'
        return
    }

    $vmHashDetails = @{
        storageId = $VirtualMachine.name
        hash = Get-VirtualMachineHash -VirtualMachine $VirtualMachine
    }
    if (-not (Test-VirtualMachineChanged -VirtualMachineHashDetails $vmHashDetails -StorageData $StorageData -VmMaxAge $VmMaxAge)) {
        return
    }

    Add-VirtualMachineToBatch -DataItem $DataItem -VirtualMachine $VirtualMachine -VirtualMachineHashDetails $vmHashDetails -LastUpdated $LastUpdated -BatchSize $BatchSize -BatchState $BatchState -Batches $Batches -BatchVmUpdates $BatchVmUpdates -Payload $Payload
}

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

        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$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')
    $utcTimestamp = $Timestamp.ToUniversalTime()
    $cacheExpiration = [System.Xml.XmlConvert]::ToTimeSpan($Config.NexthinkAPI.VmCacheExpiration)
    $vmMaxAge = $utcTimestamp.Add(-$cacheExpiration)

    $batches = [System.Collections.Generic.List[NutanixHypervisorPayload]]::new()
    $batchVmUpdates = [System.Collections.Generic.List[hashtable]]::new()

    $batchState = New-VirtualMachinesBatchState -Payload $Payload

    foreach ($dataItem in @($Payload.data)) {
        foreach ($vm in @($dataItem.virtual_machines)) {
            Add-ChangedVirtualMachineToBatch -DataItem $dataItem -VirtualMachine $vm -StorageData $storageData -VmMaxAge $vmMaxAge -LastUpdated $lastUpdated -BatchSize $BatchSize -BatchState $batchState -Batches $batches -BatchVmUpdates $batchVmUpdates -Payload $Payload
        }
    }

    Complete-VirtualMachinesBatch -BatchState $batchState -Batches $batches -BatchVmUpdates $batchVmUpdates -Payload $Payload

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

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



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

        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$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 `
            -BatchSize $Config.NexthinkAPI.RequestBatchSize

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

        Write-CustomLog -Message "VMs to send after device change detection filtering: $($diff.TotalVmCount)" -Severity 'INFO'

        $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] -ExcludeHypervisorEvents
                $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
    }
}