Private/Spool.ps1

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

    $ScriptRootPath = $Config.ScriptRootPath
    $vSphereEnvironment = $Config.EnvironmentConfig.Name

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

    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)]
        [VSphereConnectorConfiguration]$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)]
        [VSphereConnectorConfiguration]$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 -Depth 20

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

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


        $result = [VSphereConnectorSpoolReceived]::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-VSphereHypervisorPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Value
    )

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

    $dataItems = @()
    foreach ($d in @($Value.data)) {
        if ($null -eq $d) { continue }

        $di = [VSphereHypervisorDataItem]::new()

        if ($null -ne $d.host) {
            $hostInfo = [VSphereHypervisorHostInfo]::new()
            $hostInfo.name = $d.host.name
            $hostInfo.cluster = $d.host.cluster
            $hostInfo.number_of_vms = $d.host.number_of_vms
            $hostInfo.power_policy = $d.host.power_policy
            $hostInfo.hyperthreading = $d.host.hyperthreading
            $di.host = $hostInfo
        }

        $events = @()
        foreach ($e in @($d.events)) {
            if ($null -eq $e) { continue }

            $ev = [VSphereHypervisorEvent]::new()
            $ev.start_time = ConvertTo-Rfc3339UtcZ -Timestamp $e.start_time
            $ev.duration = $e.duration

            if ($null -ne $e.cpu) {
                $cpu = [VSphereHypervisorCpuMetrics]::new()
                $cpu.ready_summation = $e.cpu.ready_summation
                $cpu.usage_average = $e.cpu.usage_average
                $cpu.used_summation = $e.cpu.used_summation
                $ev.cpu = $cpu
            }

            if ($null -ne $e.disk) {
                $disk = [VSphereHypervisorDiskMetrics]::new()
                $disk.read_average = $e.disk.read_average
                $disk.write_average = $e.disk.write_average
                $disk.max_total_latency_latest = $e.disk.max_total_latency_latest
                $ev.disk = $disk
            }

            if ($null -ne $e.memory) {
                $mem = [VSphereHypervisorMemoryMetrics]::new()
                $mem.swap_in_rate_average = $e.memory.swap_in_rate_average
                $mem.swap_out_rate_average = $e.memory.swap_out_rate_average
                $mem.swap_used_average = $e.memory.swap_used_average
                $mem.state_latest = $e.memory.state_latest
                $mem.vm_mem_ctl_average = $e.memory.vm_mem_ctl_average
                $mem.usage_average = $e.memory.usage_average
                $ev.memory = $mem
            }

            $events += $ev
        }

        $di.events = @($events)
        $dataItems += $di
    }

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

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

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

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

        $cutoffUtc = 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 $receivedTimestampUtc -and $receivedTimestampUtc -le $cutoffUtc) {
                continue
            }

            $received = Read-SpoolReceived -Path $file.FullName
            $payloads += @(ConvertTo-VSphereHypervisorPayload -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)]
        [VSphereConnectorConfiguration]$Config,

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

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

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

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

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


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)]
        [VSphereConnectorConfiguration]$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'
    }
}