Private/Spool.ps1

function Get-SpoolDir {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config
    )

    $ScriptRootPath = $Config.ScriptRootPath
    $nutanixEnvironment = $Config.EnvironmentConfig.Name

    $spoolDir = Join-Path -Path $ScriptRootPath -ChildPath (Join-Path -Path $script:SPOOL_FOLDER_NAME -ChildPath $nutanixEnvironment)

    if (-not (Test-Path -Path $spoolDir)) {
        New-Item -Path $spoolDir -ItemType Directory -Force | Out-Null
        Write-CustomLog -Message "Created spool directory: $spoolDir" -Severity 'INFO'
    }

    return $spoolDir
}

function New-SpoolFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

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

    $spoolDir = Get-SpoolDir -Config $Config

    $ts = ConvertTo-FilesafeTimestamp -Timestamp $Timestamp
    $id = [Guid]::NewGuid().ToString('N')

    return (Join-Path -Path $spoolDir -ChildPath "$ts-received-$id.json")
}

function Get-SpoolFilesPending {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config
    )

    $spoolDir = Get-SpoolDir -Config $Config

    return Get-ChildItem -Path $spoolDir -Filter "*-received-*.json" -File | Sort-Object -Property Name
}

function Read-SpoolReceived {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )


    try {
        if (-not (Test-Path -Path $Path -PathType Leaf)) {
            throw "Spool file not found."
        }

        $json = Get-Content -Path $Path -Raw -Encoding utf8
        $data = $json | ConvertFrom-Json

        if (-not $data.schema_version) {
            throw "Invalid spool file: missing 'schema_version'"
        }

        if (-not $data.content) {
            throw "Invalid spool file: missing 'content' section"
        }


        $result = [NutanixConnectorSpoolReceived]::new()
        $result.schema_version = $data.schema_version
        $result.content = $data.content

        return $result
    }
    catch {
        $errorMessage = "Failed to read spool received file '$Path'. Error=$($_.Exception.Message)"
        Write-CustomLog -Message $errorMessage -Severity 'ERROR'
        throw $errorMessage
    }
}

function ConvertTo-NutanixHypervisorHostInfo {
    param([object]$Value)

    if ($null -eq $Value) {
        return $null
    }

    $hostInfo = [NutanixHypervisorHostInfo]::new()
    $hostInfo.name = $Value.name
    $hostInfo.cluster = $Value.cluster
    $hostInfo.number_of_vms = $Value.number_of_vms
    $hostInfo.power_policy = $Value.power_policy
    $hostInfo.hyperthreading = $Value.hyperthreading
    return $hostInfo
}

function ConvertTo-NutanixHypervisorEvent {
    param([object]$Value)

    $hypervisorEvent = [NutanixHypervisorEvent]::new()
    $hypervisorEvent.start_time = ConvertTo-Rfc3339UtcZ -Timestamp $Value.start_time
    $hypervisorEvent.duration = $Value.duration

    if ($null -ne $Value.cpu) {
        $cpu = [NutanixHypervisorCpuMetrics]::new()
        $cpu.number_of_threads = $Value.cpu.number_of_threads
        $cpu.number_of_packages = $Value.cpu.number_of_packages
        $cpu.number_of_vcpus = $Value.cpu.number_of_vcpus
        $cpu.ready_summation = $Value.cpu.ready_summation
        $cpu.usage_average = $Value.cpu.usage_average
        $cpu.used_summation = $Value.cpu.used_summation
        $hypervisorEvent.cpu = $cpu
    }

    if ($null -ne $Value.disk) {
        $disk = [NutanixHypervisorDiskMetrics]::new()
        $disk.read_average = $Value.disk.read_average
        $disk.write_average = $Value.disk.write_average
        $disk.max_total_latency_latest = $Value.disk.max_total_latency_latest
        $hypervisorEvent.disk = $disk
    }

    if ($null -ne $Value.memory) {
        $memory = [NutanixHypervisorMemoryMetrics]::new()
        $memory.swap_in_rate_average = $Value.memory.swap_in_rate_average
        $memory.swap_out_rate_average = $Value.memory.swap_out_rate_average
        $memory.swap_used_average = $Value.memory.swap_used_average
        $memory.state_latest = $Value.memory.state_latest
        $memory.vm_mem_ctl_average = $Value.memory.vm_mem_ctl_average
        $memory.usage_average = $Value.memory.usage_average
        $memory.installed = $Value.memory.installed
        $memory.committed = $Value.memory.committed
        $hypervisorEvent.memory = $memory
    }

    return $hypervisorEvent
}

function ConvertTo-NutanixHypervisorVMInfo {
    param([object]$Value)

    $virtualMachine = [NutanixHypervisorVMInfo]::new()
    $virtualMachine.name = $Value.name
    $virtualMachine.guest_tools_version = $Value.guest_tools_version
    $virtualMachine.resource_pool = $Value.resource_pool
    $virtualMachine.cpu_limit = $Value.cpu_limit
    $virtualMachine.cpu_shares = $Value.cpu_shares
    $virtualMachine.disk_io_limit = $Value.disk_io_limit
    return $virtualMachine
}

function ConvertTo-NutanixHypervisorDataItem {
    param([object]$Value)

    $dataItem = [NutanixHypervisorDataItem]::new()
    $dataItem.host = ConvertTo-NutanixHypervisorHostInfo -Value $Value.host

    $events = @()
    foreach ($hypervisorEvent in @($Value.events)) {
        if ($null -ne $hypervisorEvent) {
            $events += ConvertTo-NutanixHypervisorEvent -Value $hypervisorEvent
        }
    }
    $dataItem.events = @($events)

    $virtualMachines = @()
    foreach ($virtualMachine in @($Value.virtual_machines)) {
        if ($null -ne $virtualMachine) {
            $virtualMachines += ConvertTo-NutanixHypervisorVMInfo -Value $virtualMachine
        }
    }
    $dataItem.virtual_machines = @($virtualMachines)
    return $dataItem
}

function ConvertTo-NutanixHypervisorPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Value
    )

    $p = [NutanixHypervisorPayload]::new()
    $p.schema_version = $Value.schema_version
    $p.source = $Value.source
    $p.customer_environment = $Value.customer_environment
    $p.version = $Value.version

    $dataItems = @()
    foreach ($dataItem in @($Value.data)) {
        if ($null -ne $dataItem) {
            $dataItems += ConvertTo-NutanixHypervisorDataItem -Value $dataItem
        }
    }

    $p.data = @($dataItems)
    return $p
}

function Read-SpoolData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

        [Parameter(Mandatory = $true)]
        [NutanixConnectorState]$State
    )

    try {
        $files = @(Get-SpoolFilesPending -Config $Config | Sort-Object -Property Name)
        if ($files.Count -eq 0) {
            return
        }

        $cutoffUtc = if ([string]::IsNullOrWhiteSpace($State.watermarks.last_sent_utc)) {
            $null
        }
        else {
            ConvertFrom-RfcUtcTimestamp -Value $State.watermarks.last_sent_utc
        }

        $payloads = @()
        foreach ($file in $files) {
            $tsPart = ($file.BaseName -split '-received-')[0]
            $tsPart = ConvertFrom-FilesafeTimestamp -Value $tsPart
            $receivedTimestampUtc = ConvertFrom-RfcUtcTimestampOrNull -Value $tsPart

            if ($null -ne $cutoffUtc -and $null -ne $receivedTimestampUtc -and $receivedTimestampUtc -le $cutoffUtc) {
                continue
            }

            $received = Read-SpoolReceived -Path $file.FullName
            $payloads += @(ConvertTo-NutanixHypervisorPayload -Value $received.content)
        }

        if ($payloads.Count -eq 0) {
            return
        }

        return $payloads
    }
    catch {
        $errorMessage = "Failed to read spool data. Error=$($_.Exception.Message)"
        Write-CustomLog -Message $errorMessage -Severity 'ERROR'
        throw $errorMessage
    }
}

function Write-SpoolReceived {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

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

        [Parameter(Mandatory = $true)]
        [object]$Content
    )

    $path = New-SpoolFile -Config $Config -Timestamp $Timestamp

    $contentToSpool = Clear-SpoolContent -Content $Content

    $obj = [pscustomobject]@{
        schema_version = "1.0.0"
        content        = $contentToSpool
    }

    Write-SpoolFileAtomic -Path $path -Object $obj
    return $path
}

function Clear-SpoolContent {
    [CmdletBinding()]
    param(
        [object]$Content
    )

    # Clone before sanitizing to avoid mutating the in-memory payload used by the caller.
    $sanitizedContent = ($Content | ConvertTo-Json -Depth 20) | ConvertFrom-Json

    foreach ($dataItem in @($sanitizedContent.data)) {
        if ($null -eq $dataItem) { continue }

        if ($null -ne $dataItem.PSObject.Properties['virtual_machines']) {
            $dataItem.PSObject.Properties.Remove('virtual_machines')
        }
    }

    return $sanitizedContent
}


function Write-SpoolFileAtomic {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [object]$Object
    )

    try {
        $json = $Object | ConvertTo-Json -Depth 20
        $tempPath = "$Path.tmp-$([guid]::NewGuid().ToString('N'))"


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

        Write-CustomLog -Message "Spooled file written atomically: '$Path'" -Severity 'INFO'
    }
    catch {
        $errorMessage = "Failed to write spool file. Path=$Path Error=$($_.Exception.Message)"
        Write-CustomLog -Message $errorMessage -Severity 'ERROR'
        throw $errorMessage
    }
}

function Remove-SpoolStaleFiles {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

        [Parameter(Mandatory = $true)]
        [int]$MaxFileCount
    )

    $spoolDir = Get-SpoolDir -Config $Config

    try {

        $files = Get-ChildItem -Path $spoolDir -Filter '*-received-*.json' -File | Sort-Object -Property Name


        if ($files.Count -le $MaxFileCount) {
            return
        }

        $toDelete = $files | Select-Object -First ($files.Count - $MaxFileCount)
        foreach ($file in $toDelete) {
            try {
                Remove-Item -Path $file.FullName -Force
                Write-CustomLog -Message "Spool cleanup deleted: $($file.Name)" -Severity 'DEBUG'
            }
            catch {
                Write-CustomLog -Message "Failed to delete spool file '$($file.Name)'. Error=$($_.Exception.Message)" -Severity 'WARNING'
            }
        }
    }
    catch {
        Write-CustomLog -Message "Spool cleanup failed. Dir=$spoolDir Error=$($_.Exception.Message)" -Severity 'WARNING'
    }
}