Private/AWS/New-SilkTCOAWSFSxCostArray.ps1

<#
    .SYNOPSIS
    Calculates AWS FSx for NetApp ONTAP costs from the AWS Pricing API.

    .DESCRIPTION
    Prices the four things FSx ONTAP actually bills for: SSD capacity, capacity pool
    (tiered) capacity, provisioned throughput, and provisioned IOPS above baseline.
    Falls back to rough hardcoded rates if the API lookup fails. List price only -
    no reservations or savings plans are considered.

    .EXAMPLE
    New-SilkTCOAWSFSxCostArray -fslist $fslist -metrics $metrics -region 'us-east-1' -days 30
#>


function New-SilkTCOAWSFSxCostArray {
    param(
        [Parameter(Mandatory)]
        [array] $fslist,
        [Parameter()]
        [array] $metrics,
        [Parameter()]
        [string] $region,
        [Parameter()]
        [int] $days = 1
    )

    # figure out region from the metrics rows if not passed
    if (-not $region -and $metrics) {
        $r = ($metrics | Where-Object { $_.Region -and $_.Region -ne 'N/A' } | Select-Object -First 1).Region
        if ($r) {
            $region = $r
            Write-Verbose "Auto-detected region from fsx metrics: $region" -Verbose
        }
    }

    if (-not $region) {
        $region = 'us-east-1'
        Write-Warning "No region detected, defaulting to: $region"
    }

    $requiredModules = @('AWS.Tools.Pricing')
    foreach ($module in $requiredModules) {
        if (-not (Get-Module -ListAvailable -Name $module)) {
            throw "Required module '$module' is not installed. Install it with: Install-Module $module"
        }
        if (-not (Get-Module -Name $module)) {
            Import-Module $module -ErrorAction Stop
        }
    }

    Write-Verbose "Querying AWS Pricing API for FSx ONTAP rates..." -Verbose

    # region code -> pricing API location name. wider list than the ec2/rds copies since
    # fsx rates move around more by region and a silent us-east-1 fallback is a real problem
    $regionMapping = @{
        'us-east-1'      = 'US East (N. Virginia)'
        'us-east-2'      = 'US East (Ohio)'
        'us-west-1'      = 'US West (N. California)'
        'us-west-2'      = 'US West (Oregon)'
        'ca-central-1'   = 'Canada (Central)'
        'sa-east-1'      = 'South America (Sao Paulo)'
        'eu-west-1'      = 'EU (Ireland)'
        'eu-west-2'      = 'EU (London)'
        'eu-west-3'      = 'EU (Paris)'
        'eu-central-1'   = 'EU (Frankfurt)'
        'eu-north-1'     = 'EU (Stockholm)'
        'eu-south-1'     = 'EU (Milan)'
        'ap-east-1'      = 'Asia Pacific (Hong Kong)'
        'ap-south-1'     = 'Asia Pacific (Mumbai)'
        'ap-southeast-1' = 'Asia Pacific (Singapore)'
        'ap-southeast-2' = 'Asia Pacific (Sydney)'
        'ap-southeast-3' = 'Asia Pacific (Jakarta)'
        'ap-northeast-1' = 'Asia Pacific (Tokyo)'
        'ap-northeast-2' = 'Asia Pacific (Seoul)'
        'ap-northeast-3' = 'Asia Pacific (Osaka)'
        'me-south-1'     = 'Middle East (Bahrain)'
        'af-south-1'     = 'Africa (Cape Town)'
    }

    if ($regionMapping.ContainsKey($region)) {
        $pricingRegion = $regionMapping[$region]
    } else {
        # dont silently price everything as n.virginia, say so
        $pricingRegion = 'US East (N. Virginia)'
        Write-Warning "Region '$region' is not in the FSx pricing map - falling back to '$pricingRegion'. Costs will be wrong for this region."
    }

    $rateCache = @{}

    # generic pricing lookup against AmazonFSx. filters is an array of TERM_MATCH hashes.
    function Get-FSxRate {
        param(
            [string] $CacheKey,
            [array]  $Filters
        )

        if ($rateCache.ContainsKey($CacheKey)) {
            return $rateCache[$CacheKey]
        }

        try {
            $products = Get-PLSProduct -ServiceCode AmazonFSx -Filter $Filters -MaxResult 1 -Region us-east-1

            if ($products) {
                # Get-PLSProduct hands back one big string, indexing it grabs a character.
                # wrap in @() first or ConvertFrom-Json chokes (same trap as the rds array)
                $priceJson = @($products)[0] | ConvertFrom-Json
                $terms = $priceJson.terms.OnDemand
                $firstTerm = $terms.PSObject.Properties.Value | Select-Object -First 1
                $priceDimension = $firstTerm.priceDimensions.PSObject.Properties.Value | Select-Object -First 1
                $rate = [decimal]$priceDimension.pricePerUnit.USD

                $rateCache[$CacheKey] = $rate
                return $rate
            }
        } catch {
            Write-Verbose "Failed FSx pricing lookup for $CacheKey : $_" -Verbose
        }

        return $null
    }

    # rough us-east-1 list rates, used when the api lookup comes back empty.
    # single-az figures - multi-az roughly doubles capacity and throughput
    $fallback = @{
        'ssd'        = 0.125    # per GB-month
        'pool'       = 0.0219   # per GB-month
        'throughput' = 0.72     # per MBps-month
        'iops'       = 0.0060   # per IOPS-month above baseline
        'backup'     = 0.05     # per GB-month
    }

    $costReport = @()

    foreach ($fs in $fslist) {
        $fsId = $fs.FileSystemId
        $ontap = $fs.OntapConfiguration
        $deployRaw = $ontap.DeploymentType.Value
        $deployment = if ($deployRaw -like 'MULTI_AZ*') { 'Multi-AZ' } else { 'Single-AZ' }
        $azMultiplier = if ($deployment -eq 'Multi-AZ') { 2 } else { 1 }

        $haPairs = if ($ontap.HAPairs) { $ontap.HAPairs } else { 1 }
        $tputPerPair = if ($ontap.ThroughputCapacityPerHAPair) { $ontap.ThroughputCapacityPerHAPair } else { $ontap.ThroughputCapacity }
        $totalThroughput = $tputPerPair * $haPairs

        $m = $metrics | Where-Object { $_.FileSystemId -eq $fsId } | Select-Object -First 1

        # --- ssd capacity ---
        $ssdFilters = @(
            @{Type='TERM_MATCH'; Field='productFamily'; Value='Storage'}
            @{Type='TERM_MATCH'; Field='fileSystemType'; Value='ONTAP'}
            @{Type='TERM_MATCH'; Field='storageType'; Value='SSD'}
            @{Type='TERM_MATCH'; Field='deploymentOption'; Value=$deployment}
            @{Type='TERM_MATCH'; Field='location'; Value=$pricingRegion}
        )
        $ssdRate = Get-FSxRate -CacheKey "ssd-$deployment-$pricingRegion" -Filters $ssdFilters
        if (-not $ssdRate) { $ssdRate = $fallback['ssd'] * $azMultiplier }

        $ssdGB = $fs.StorageCapacity
        if ($ssdGB -and $ssdRate) {
            $dailySsd = [Math]::Round((($ssdRate * $ssdGB) / 30), 4)
            $periodSsd = [Math]::Round($dailySsd * $days, 4)

            $costItem = New-Object psobject
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $fsId
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value 'SSD'
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'FSx ONTAP Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'FSxN-SSD'
            $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $periodSsd
            $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
            $costReport += $costItem

            Write-Verbose " FSx: $fsId SSD ${ssdGB}GB = `$$dailySsd/day x $days days = `$$periodSsd" -Verbose
        }

        # --- capacity pool (tiered) storage. this is usually where most of the data lives ---
        $poolGB = if ($m -and $m.CapacityPoolUsedGB) { $m.CapacityPoolUsedGB } else { 0 }
        if ($poolGB -gt 0) {
            $poolFilters = @(
                @{Type='TERM_MATCH'; Field='productFamily'; Value='Storage'}
                @{Type='TERM_MATCH'; Field='fileSystemType'; Value='ONTAP'}
                @{Type='TERM_MATCH'; Field='storageType'; Value='Capacity Pool'}
                @{Type='TERM_MATCH'; Field='location'; Value=$pricingRegion}
            )
            $poolRate = Get-FSxRate -CacheKey "pool-$pricingRegion" -Filters $poolFilters
            if (-not $poolRate) { $poolRate = $fallback['pool'] }

            $dailyPool = [Math]::Round((($poolRate * $poolGB) / 30), 4)
            $periodPool = [Math]::Round($dailyPool * $days, 4)

            $costItem = New-Object psobject
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $fsId
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value 'CapacityPool'
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'FSx ONTAP Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'FSxN-CapacityPool'
            $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $periodPool
            $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
            $costReport += $costItem

            Write-Verbose " Capacity pool ${poolGB}GB = `$$dailyPool/day x $days days = `$$periodPool" -Verbose
        } elseif ($m) {
            # no datapoint doesnt mean no tiered data, it might just be a permissions/metric gap
            Write-Warning "$fsId - no capacity pool usage metric returned. If tiering is enabled this UNDERSTATES the storage bill."
        }

        # --- provisioned throughput. this is the 'compute' half of fsx ---
        $tputFilters = @(
            @{Type='TERM_MATCH'; Field='productFamily'; Value='Provisioned Throughput'}
            @{Type='TERM_MATCH'; Field='fileSystemType'; Value='ONTAP'}
            @{Type='TERM_MATCH'; Field='deploymentOption'; Value=$deployment}
            @{Type='TERM_MATCH'; Field='location'; Value=$pricingRegion}
        )
        $tputRate = Get-FSxRate -CacheKey "tput-$deployment-$pricingRegion" -Filters $tputFilters
        if (-not $tputRate) { $tputRate = $fallback['throughput'] * $azMultiplier }

        if ($totalThroughput -and $tputRate) {
            $dailyTput = [Math]::Round((($tputRate * $totalThroughput) / 30), 4)
            $periodTput = [Math]::Round($dailyTput * $days, 4)

            $costItem = New-Object psobject
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $fsId
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value "${totalThroughput}MBps"
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'FSx ONTAP Throughput'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Compute'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'FSxN-Throughput'
            $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $periodTput
            $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
            $costReport += $costItem

            Write-Verbose " Throughput ${totalThroughput}MBps ($haPairs HA pair(s)) = `$$dailyTput/day x $days days = `$$periodTput" -Verbose
        }

        # --- provisioned iops, only the bit above the 3 iops/GB baseline is chargeable ---
        if ($ontap.DiskIopsConfiguration.Mode.Value -eq 'USER_PROVISIONED' -and $ontap.DiskIopsConfiguration.Iops) {
            $baselineIops = $ssdGB * 3
            $billableIops = $ontap.DiskIopsConfiguration.Iops - $baselineIops

            if ($billableIops -gt 0) {
                $iopsFilters = @(
                    @{Type='TERM_MATCH'; Field='productFamily'; Value='Provisioned IOPS'}
                    @{Type='TERM_MATCH'; Field='fileSystemType'; Value='ONTAP'}
                    @{Type='TERM_MATCH'; Field='deploymentOption'; Value=$deployment}
                    @{Type='TERM_MATCH'; Field='location'; Value=$pricingRegion}
                )
                $iopsRate = Get-FSxRate -CacheKey "iops-$deployment-$pricingRegion" -Filters $iopsFilters
                if (-not $iopsRate) { $iopsRate = $fallback['iops'] * $azMultiplier }

                $dailyIops = [Math]::Round((($iopsRate * $billableIops) / 30), 4)
                $periodIops = [Math]::Round($dailyIops * $days, 4)

                $costItem = New-Object psobject
                $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $fsId
                $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value "${billableIops}IOPS"
                $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'FSx ONTAP IOPS'
                $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Compute'
                $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'FSxN-IOPS'
                $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $periodIops
                $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
                $costReport += $costItem
            }
        }
    }

    if ($costReport.Count -eq 0) {
        Write-Warning "No FSx cost data calculated for the provided file systems."
        return
    }

    Write-Verbose "Calculated FSx ONTAP costs for $($costReport.Count) line items." -Verbose
    return $costReport
}