Private/AWS/New-SilkTCOAWSRDSCostArray.ps1

<#
    .SYNOPSIS
    Calculates AWS RDS costs based on the AWS Pricing API.

    .DESCRIPTION
    Calculates instance + storage (and provisioned IOPS) costs for standard
    RDS database instances using the AWS Price List Service API. Falls back to
    hardcoded rates if the API lookup fails. Costs are for the specified number
    of days (default: 1 day).

    .EXAMPLE
    New-SilkTCOAWSRDSCostArray -rdslist $rdslist -region 'us-east-1' -days 7
#>


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

    # figure out region from the db list if not passed
    if (-not $region -and $rdslist.Count -gt 0) {
        $az = $rdslist[0].AvailabilityZone
        if ($az) { $region = $az -replace '[a-z]$', '' }
        Write-Verbose "Auto-detected region from rdslist: $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 RDS rates..." -Verbose

    # region code -> pricing API location name
    $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)'
        'eu-west-1'      = 'EU (Ireland)'
        'eu-central-1'   = 'EU (Frankfurt)'
        'ap-southeast-1' = 'Asia Pacific (Singapore)'
        'ap-southeast-2' = 'Asia Pacific (Sydney)'
        'ap-northeast-1' = 'Asia Pacific (Tokyo)'
    }

    $pricingRegion = if ($regionMapping.ContainsKey($region)) {
        $regionMapping[$region]
    } else {
        'US East (N. Virginia)'
    }

    # RDS engine value -> pricing API databaseEngine name
    $engineMapping = @{
        'postgres'      = 'PostgreSQL'
        'mysql'         = 'MySQL'
        'mariadb'       = 'MariaDB'
        'oracle-se2'    = 'Oracle'
        'oracle-se2-cdb' = 'Oracle'
        'oracle-ee'     = 'Oracle'
        'oracle-ee-cdb' = 'Oracle'
        'sqlserver-se'  = 'SQL Server'
        'sqlserver-ee'  = 'SQL Server'
        'sqlserver-ex'  = 'SQL Server'
        'sqlserver-web' = 'SQL Server'
    }

    # storage type -> pricing volumeType name
    $volumeTypeMapping = @{
        'gp2'      = 'General Purpose'
        'gp3'      = 'General Purpose-GP3'
        'io1'      = 'Provisioned IOPS'
        'io2'      = 'Provisioned IOPS'
        'standard' = 'Magnetic'
    }

    $instanceCache = @{}
    $storageCache = @{}

    # rds instance hourly rate from the api
    function Get-RDSInstancePricing {
        param(
            [string] $InstanceClass,
            [string] $Engine,
            [string] $Deployment,
            [string] $Region
        )

        $cacheKey = "$InstanceClass-$Engine-$Deployment-$Region"
        if ($instanceCache.ContainsKey($cacheKey)) {
            return $instanceCache[$cacheKey]
        }

        try {
            $filters = @(
                @{Type='TERM_MATCH'; Field='productFamily'; Value='Database Instance'}
                @{Type='TERM_MATCH'; Field='instanceType'; Value=$InstanceClass}
                @{Type='TERM_MATCH'; Field='databaseEngine'; Value=$Engine}
                @{Type='TERM_MATCH'; Field='deploymentOption'; Value=$Deployment}
                @{Type='TERM_MATCH'; Field='location'; Value=$Region}
            )

            $products = Get-PLSProduct -ServiceCode AmazonRDS -Filter $filters -MaxResult 1 -Region us-east-1

            if ($products) {
                # Get-PLSProduct returns one big string, not an array. indexing it grabs
                # the first CHARACTER, so wrap in @() first or the json parse blows up
                # $priceJson = $products[0] | ConvertFrom-Json
                $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
                $pricePerHour = [decimal]$priceDimension.pricePerUnit.USD

                $instanceCache[$cacheKey] = $pricePerHour
                return $pricePerHour
            }
        } catch {
            Write-Verbose "Failed to get RDS instance pricing for $InstanceClass from API: $_" -Verbose
        }

        # rough fallback rates (single-az, postgres-ish). doubled below for multi-az
        $fallback = @{
            'db.t3.small' = 0.034; 'db.t3.medium' = 0.068; 'db.t3.large' = 0.136
            'db.t3.xlarge' = 0.272; 'db.t3.2xlarge' = 0.544
            'db.t4g.micro' = 0.016; 'db.t4g.small' = 0.032; 'db.t4g.medium' = 0.065
            'db.t4g.large' = 0.129; 'db.t4g.xlarge' = 0.258; 'db.t4g.2xlarge' = 0.516
            'db.m5.large' = 0.178; 'db.m5.xlarge' = 0.356; 'db.m5.2xlarge' = 0.712
            'db.m5.4xlarge' = 1.424; 'db.r5.large' = 0.24; 'db.r5.xlarge' = 0.48
        }
        $rate = $fallback[$InstanceClass]
        if ($rate -and $Deployment -eq 'Multi-AZ') { $rate = $rate * 2 }
        return $rate
    }

    # rds storage GB-month rate from the api
    function Get-RDSStoragePricing {
        param(
            [string] $VolumeType,
            [string] $Deployment,
            [string] $Region
        )

        $cacheKey = "$VolumeType-$Deployment-$Region"
        if ($storageCache.ContainsKey($cacheKey)) {
            return $storageCache[$cacheKey]
        }

        try {
            $filters = @(
                @{Type='TERM_MATCH'; Field='productFamily'; Value='Database Storage'}
                @{Type='TERM_MATCH'; Field='volumeType'; Value=$VolumeType}
                @{Type='TERM_MATCH'; Field='deploymentOption'; Value=$Deployment}
                @{Type='TERM_MATCH'; Field='location'; Value=$Region}
            )

            $products = Get-PLSProduct -ServiceCode AmazonRDS -Filter $filters -MaxResult 1 -Region us-east-1

            if ($products) {
                # Get-PLSProduct returns one big string, not an array. indexing it grabs
                # the first CHARACTER, so wrap in @() first or the json parse blows up
                # $priceJson = $products[0] | ConvertFrom-Json
                $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
                $pricePerUnit = [decimal]$priceDimension.pricePerUnit.USD

                $storageCache[$cacheKey] = $pricePerUnit
                return $pricePerUnit
            }
        } catch {
            Write-Verbose "Failed to get RDS storage pricing for $VolumeType from API: $_" -Verbose
        }

        # fallback GB-month rates (single-az)
        $fallback = @{
            'General Purpose' = 0.115; 'General Purpose-GP3' = 0.115
            'Provisioned IOPS' = 0.125; 'Magnetic' = 0.10
        }
        $rate = $fallback[$VolumeType]
        if ($rate -and $Deployment -eq 'Multi-AZ') { $rate = $rate * 2 }
        return $rate
    }

    $costReport = @()

    foreach ($db in $rdslist) {
        $dbId = $db.DBInstanceIdentifier
        $instanceClass = $db.DBInstanceClass
        $deployment = if ($db.MultiAZ) { 'Multi-AZ' } else { 'Single-AZ' }
        $engine = if ($engineMapping.ContainsKey($db.Engine)) { $engineMapping[$db.Engine] } else { $db.Engine }

        # --- instance cost ---
        $hourlyRate = Get-RDSInstancePricing -InstanceClass $instanceClass -Engine $engine -Deployment $deployment -Region $pricingRegion

        if ($hourlyRate) {
            $dailyCost = [Math]::Round($hourlyRate * 24, 4)
            $totalCost = [Math]::Round($dailyCost * $days, 4)

            $costItem = New-Object psobject
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $dbId
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value $instanceClass
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'RDS Instance'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Compute'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'RDS-Instances'
            $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $totalCost
            $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
            $costReport += $costItem

            Write-Verbose " RDS: $dbId ($instanceClass/$deployment) = `$$dailyCost/day x $days days = `$$totalCost" -Verbose
        } else {
            Write-Warning "No pricing data available for RDS class: $instanceClass"
        }

        # --- storage cost ---
        $volType = if ($volumeTypeMapping.ContainsKey($db.StorageType)) { $volumeTypeMapping[$db.StorageType] } else { 'General Purpose' }
        $gbMonthRate = Get-RDSStoragePricing -VolumeType $volType -Deployment $deployment -Region $pricingRegion

        $dailyStorageCost = 0
        if ($gbMonthRate -and $db.AllocatedStorage) {
            $monthly = $gbMonthRate * $db.AllocatedStorage
            $dailyStorageCost = [Math]::Round($monthly / 30, 4)
        }

        # provisioned iops adder for io1/io2 (hardcoded, api query is fiddly)
        $dailyIopsCost = 0
        if ($db.StorageType -in @('io1', 'io2') -and $db.Iops) {
            $iopsMonthlyRate = 0.10  # ~$0.10 per IOPS-month for RDS io1
            if ($deployment -eq 'Multi-AZ') { $iopsMonthlyRate = $iopsMonthlyRate * 2 }
            $dailyIopsCost = [Math]::Round(($iopsMonthlyRate * $db.Iops) / 30, 4)
        }

        $totalDaily = $dailyStorageCost + $dailyIopsCost
        $totalStoragePeriod = [Math]::Round($totalDaily * $days, 4)

        if ($totalStoragePeriod -gt 0) {
            $costItem = New-Object psobject
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceId -Value $dbId
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceName -Value $db.StorageType
            $costItem | Add-Member -MemberType NoteProperty -Name ResourceType -Value 'RDS Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterCategory -Value 'Storage'
            $costItem | Add-Member -MemberType NoteProperty -Name MeterSubCategory -Value 'RDS'
            $costItem | Add-Member -MemberType NoteProperty -Name Cost -Value $totalStoragePeriod
            $costItem | Add-Member -MemberType NoteProperty -Name Currency -Value 'USD'
            $costReport += $costItem

            Write-Verbose " Storage: $dbId ($($db.StorageType) $($db.AllocatedStorage)GB) = `$$totalDaily/day x $days days = `$$totalStoragePeriod" -Verbose
        }
    }

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

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