Private/NutanixApi.ps1

<#
Low-level HTTP/auth/pagination/rate-limiting for Prism Central v4 REST.

Unlike vCenter (PowerCLI/SOAP session), Prism Central v4 is stateless plain REST with
per-request HTTP Basic auth, so there is no Connect/Disconnect session step here -
Get-NutanixApiContext just resolves credentials and builds the base URI + auth header.

Reference: https://developers.nutanix.com/api-reference-v4/ ,
           https://www.nutanix.dev/nutanix-api-user-guide/ (pagination, rate limits)
#>


function Get-NutanixApiContext {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixEnvironmentConfiguration]$EnvironmentConfig
    )

    Write-CustomLog -Message "Resolving credentials for Nutanix Prism Central: $($EnvironmentConfig.PrismCentralFQDN)" -Severity 'DEBUG'
    $credential = Get-SecureCredential -Target $EnvironmentConfig.WindowsCredentialEntry

    $username = $credential.UserName
    $password = $credential.GetNetworkCredential().Password
    $basicHeader = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${username}:${password}"))

    return [pscustomobject]@{
        BaseUri = "https://$($EnvironmentConfig.PrismCentralFQDN):$($EnvironmentConfig.PrismCentralPort)/api"
        Headers = @{
            'Authorization' = "Basic $basicHeader"
            'Accept'        = 'application/json'
        }
        SkipCertificateCheck = $EnvironmentConfig.SkipCertificateCheck
        RateGate = [System.Threading.SemaphoreSlim]::new(1, 1)
        RequestStarts = [System.Collections.Concurrent.ConcurrentQueue[long]]::new()
    }
}

function Build-NutanixQueryString {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [hashtable]$Query = @{}
    )

    if ($null -eq $Query -or $Query.Count -eq 0) {
        return ''
    }

    $pairs = foreach ($key in $Query.Keys) {
        $value = $Query[$key]
        if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { continue }
        "{0}={1}" -f [Uri]::EscapeDataString($key), [Uri]::EscapeDataString([string]$value)
    }

    if ($pairs.Count -eq 0) { return '' }
    return '?' + ($pairs -join '&')
}

function Wait-NutanixApiRequestSlot {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$Context
    )

    while ($true) {
        [void]$Context.RateGate.Wait()
        $slotAcquired = $false
        $waitMilliseconds = 1
        try {
            $nowMilliseconds = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
            [long]$oldestStart = 0
            while ($Context.RequestStarts.TryPeek([ref]$oldestStart) -and
                ($nowMilliseconds - $oldestStart) -ge 1000) {
                [long]$discardedStart = 0
                $null = $Context.RequestStarts.TryDequeue([ref]$discardedStart)
            }

            if ($Context.RequestStarts.Count -lt $script:NUTANIX_API_MAX_REQUESTS_PER_SECOND) {
                $Context.RequestStarts.Enqueue($nowMilliseconds)
                $slotAcquired = $true
            }
            elseif ($Context.RequestStarts.TryPeek([ref]$oldestStart)) {
                $waitMilliseconds = [Math]::Max(1, 1000 - ($nowMilliseconds - $oldestStart))
            }
        }
        finally {
            $null = $Context.RateGate.Release()
        }

        if ($slotAcquired) {
            return
        }
        Start-Sleep -Milliseconds $waitMilliseconds
    }
}

function Get-NutanixApiFailureStatusCode {
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    if ($null -eq $ErrorRecord.Exception.Response) {
        return $null
    }

    return [int]$ErrorRecord.Exception.Response.StatusCode.value__
}

function Get-NutanixApiRetryDelay {
    param(
        [Parameter(Mandatory = $true)]
        [int]$Attempt,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [Nullable[int]]$StatusCode,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $backoffSeconds = [Math]::Min(30, [Math]::Pow(2, $Attempt))
    if ($StatusCode -ne 429 -or $null -eq $ErrorRecord.Exception.Response.Headers) {
        return $backoffSeconds
    }

    $retryAfter = [string]$ErrorRecord.Exception.Response.Headers['Retry-After']
    $retryAfterSeconds = 0
    if ([int]::TryParse($retryAfter, [ref]$retryAfterSeconds) -and $retryAfterSeconds -gt 0) {
        return [Math]::Min(60, $retryAfterSeconds)
    }

    return $backoffSeconds
}

function Invoke-NutanixApiRequest {
    <#
    .SYNOPSIS
        GETs a single page from a Prism Central v4 endpoint, with 429 backoff and request pacing.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$Context,

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

        [Parameter(Mandatory = $false)]
        [hashtable]$Query = @{}
    )

    $uri = "$($Context.BaseUri)/$Path" + (Build-NutanixQueryString -Query $Query)

    $attempt = 0
    while ($true) {
        $attempt++
        Wait-NutanixApiRequestSlot -Context $Context
        try {
            $response = Invoke-WebRequestWithLogging -Uri $uri -Method 'GET' -Headers $Context.Headers -UseBasicParsing -SkipCertificateCheck:$Context.SkipCertificateCheck
            return ($response.Content | ConvertFrom-Json)
        }
        catch {
            $statusCode = Get-NutanixApiFailureStatusCode -ErrorRecord $_
            $isTransientFailure = ($null -eq $statusCode -or $statusCode -in @(408, 429, 502, 503, 504))
            if (-not $isTransientFailure -or $attempt -gt $script:NUTANIX_API_MAX_RETRIES) {
                throw "Nutanix API request failed. Uri=$uri Attempt=$attempt Error=$($_.Exception.Message)"
            }

            $backoffSeconds = Get-NutanixApiRetryDelay -Attempt $attempt -StatusCode $statusCode -ErrorRecord $_

            Write-CustomLog -Message "Transient Nutanix API failure (HTTP $statusCode). Backing off ${backoffSeconds}s before retry $attempt/$($script:NUTANIX_API_MAX_RETRIES). Uri=$uri" -Severity 'WARNING'
            Start-Sleep -Seconds $backoffSeconds
        }
    }
}

function Get-NutanixPaginatedResults {
    <#
    .SYNOPSIS
        Pages through a Prism Central v4 list endpoint, concatenating '.data' across pages.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$Context,

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

        [Parameter(Mandatory = $false)]
        [hashtable]$ExtraQuery = @{}
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $page = 0
    $limit = $script:NUTANIX_STATS_PAGE_LIMIT

    while ($true) {
        $query = @{} + $ExtraQuery
        $query['$limit'] = $limit
        $query['$page'] = $page

        $response = Invoke-NutanixApiRequest -Context $Context -Path $Path -Query $query
        $pageData = @($response.data)

        foreach ($item in $pageData) {
            $results.Add($item)
        }

        if ($pageData.Count -lt $limit) {
            break
        }

        $page++
    }

    return $results
}