Private/Common/Get-SilkTCOAzureCostDetails.ps1
|
<# .SYNOPSIS Pulls resource level Azure cost from the Cost Details report API. .DESCRIPTION One report per subscription + date window + metric, cached for the session, so every Azure export in the same run reads the same data rather than asking again. The window is whole UTC days, both ends inclusive. Cost Management only keeps cost per day, so a rolling 24h window touches two days and comes back doubled - which is the bug this replaces. -days 1 -offsetDays 1 is all of yesterday, nothing else. Rows come back as hashtables on purpose. A busy subscription is tens of thousands of rows a day and building a psobject for each one is painfully slow. #> function Get-SilkTCOAzureCostDetails { param( [Parameter()] [int] $days = 1, [Parameter()] [int] $offsetDays = 1, [Parameter()] [ValidateSet('AmortizedCost', 'ActualCost')] [string] $metric = 'AmortizedCost', [Parameter()] [int] $timeoutMinutes = 15 ) if (-not (Install-SilkTCOModule -Name 'Az.Accounts')) { Write-Warning "Az.Accounts unavailable - no cost data will be collected." return } $ctx = Get-AzContext if (-not $ctx) { Write-Warning "No Azure context - run Connect-AzAccount for cost data." return } $scope = "subscriptions/$($ctx.Subscription.Id)" $utcToday = (Get-Date).ToUniversalTime().Date $endDate = $utcToday.AddDays(-$offsetDays) $startDate = $endDate.AddDays(-($days - 1)) $startStr = $startDate.ToString('yyyy-MM-dd') $endStr = $endDate.ToString('yyyy-MM-dd') # every azure export in the session shares one report per window $cacheKey = "$scope|$startStr|$endStr|$metric" if (-not $script:SilkTCOCostDetailsCache) { $script:SilkTCOCostDetailsCache = @{} } if ($script:SilkTCOCostDetailsCache.ContainsKey($cacheKey)) { Write-Verbose "-> reusing cost details already pulled this session ($startStr .. $endStr, $metric)" -Verbose return $script:SilkTCOCostDetailsCache[$cacheKey] } Write-Verbose "Cost window: $startStr .. $endStr ($days day(s), $metric) for $($ctx.Subscription.Name)" -Verbose if ($offsetDays -eq 0) { Write-Warning "offsetDays 0 includes today, which is always partial - today's cost will read low." } $tok = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token if ($tok -is [securestring]) { $tok = [System.Net.NetworkCredential]::new('', $tok).Password } $headers = @{ Authorization = "Bearer $tok"; 'Content-Type' = 'application/json' } $uri = "https://management.azure.com/$scope/providers/Microsoft.CostManagement/generateCostDetailsReport?api-version=2023-11-01" # api caps a request at one month, so split on calendar month boundaries $chunks = @() $cursor = $startDate while ($cursor -le $endDate) { $monthEnd = $cursor.AddDays(1 - $cursor.Day).AddMonths(1).AddDays(-1) $chunkEnd = if ($monthEnd -lt $endDate) { $monthEnd } else { $endDate } $chunks += , @($cursor, $chunkEnd) $cursor = $chunkEnd.AddDays(1) } $inv = [System.Globalization.CultureInfo]::InvariantCulture $floatStyle = [System.Globalization.NumberStyles]::Float $dateFormats = [string[]]@('MM/dd/yyyy', 'M/d/yyyy', 'yyyy-MM-dd', 'yyyyMMdd') $rows = [System.Collections.Generic.List[hashtable]]::new() $datesSeen = @{} $costColUsed = $null foreach ($chunk in $chunks) { $body = @{ metric = $metric timePeriod = @{ start = $chunk[0].ToString('yyyy-MM-dd'); end = $chunk[1].ToString('yyyy-MM-dd') } } | ConvertTo-Json -Depth 5 # throttling here is on report generations, so back off and try again $resp = $null foreach ($wait in 0, 60, 180, 480) { if ($wait) { Write-Warning "Cost Details throttled, waiting ${wait}s before retrying..." Start-Sleep -Seconds $wait } try { $resp = Invoke-WebRequest -Uri $uri -Method Post -Headers $headers -Body $body -ErrorAction Stop break } catch { $code = [int]$_.Exception.Response.StatusCode if ($code -eq 429) { continue } if ($code -in 401, 403) { Write-Warning "Cost Details refused ($code). The identity needs Cost Management Reader on $scope." } else { Write-Warning "Cost Details request failed: $($_.Exception.Message)" } return } } if (-not $resp) { Write-Warning "Cost Details still throttled after backing off - no cost data collected." return } # 202 means go poll, 200 means azure already had this report cached $manifest = $null if ([int]$resp.StatusCode -eq 202) { $pollUrl = "$($resp.Headers['Location'])" } else { $manifest = $resp.Content | ConvertFrom-Json } $deadline = (Get-Date).AddMinutes($timeoutMinutes) while (-not $manifest) { if ((Get-Date) -gt $deadline) { Write-Warning "Cost Details report didn't finish within $timeoutMinutes minutes." return } try { $poll = Invoke-WebRequest -Uri $pollUrl -Method Get -Headers $headers -ErrorAction Stop } catch { Write-Warning "Lost track of the Cost Details report: $($_.Exception.Message)" return } if ([int]$poll.StatusCode -eq 202) { $ra = 20; $n = 0 if ([int]::TryParse("$($poll.Headers['Retry-After'])", [ref]$n) -and $n -gt 0) { $ra = [Math]::Min(60, $n) } Start-Sleep -Seconds $ra continue } $parsed = $poll.Content | ConvertFrom-Json switch -Regex ("$($parsed.status)") { '^(Completed|NoDataFound)$' { $manifest = $parsed } '^(Failed|Cancel)' { Write-Warning "Cost Details report $($parsed.status): $($parsed.error.message)" return } default { Start-Sleep -Seconds 20 } } } if ("$($manifest.status)" -eq 'NoDataFound') { continue } foreach ($blob in @($manifest.manifest.blobs)) { $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("silktco-costdetails-" + [guid]::NewGuid().ToString('n') + '.csv') try { # sas link - sending an auth header gets it rejected Invoke-WebRequest -Uri $blob.blobLink -OutFile $tmp -ErrorAction Stop $csv = @(Import-Csv -Path $tmp) } catch { Write-Warning "Couldn't download the Cost Details file: $($_.Exception.Message)" return } finally { Remove-Item -Path $tmp -Force -ErrorAction SilentlyContinue } if (-not $csv.Count) { continue } # column names move around by billing account type. usd first, since every # cost column we write out is labelled usd $hdr = @($csv[0].PSObject.Properties.Name) $idCol = @('ResourceId', 'InstanceId', 'InstanceName') | Where-Object { $hdr -contains $_ } | Select-Object -First 1 $costCol = @('costInUsd', 'costInBillingCurrency', 'Cost', 'PreTaxCost') | Where-Object { $hdr -contains $_ } | Select-Object -First 1 $dateCol = @('date', 'UsageDate', 'UsageDateTime') | Where-Object { $hdr -contains $_ } | Select-Object -First 1 if (-not $idCol -or -not $costCol) { Write-Warning "Cost Details file has no recognisable resource id / cost column. Got: $($hdr -join ', ')" return } $costColUsed = $costCol foreach ($line in $csv) { # no resource id = subscription level charge, nothing to hang it on $rid = "$($line.$idCol)".ToLower() if (-not $rid.StartsWith('/subscriptions/')) { continue } $cost = 0.0 if (-not [double]::TryParse("$($line.$costCol)", $floatStyle, $inv, [ref]$cost)) { continue } $day = '' if ($dateCol) { $dt = [datetime]::MinValue if ([datetime]::TryParseExact("$($line.$dateCol)", $dateFormats, $inv, [System.Globalization.DateTimeStyles]::None, [ref]$dt)) { $day = $dt.ToString('yyyy-MM-dd') $datesSeen[$day] = $true } } $qty = $null; $q = 0.0 if ([double]::TryParse("$($line.quantity)", $floatStyle, $inv, [ref]$q)) { $qty = $q } $price = $null; $pr = 0.0 if ([double]::TryParse("$($line.unitPrice)", $floatStyle, $inv, [ref]$pr)) { $price = $pr } $rows.Add(@{ ResourceId = $rid Date = $day Cost = $cost MeterCategory = "$($line.meterCategory)" MeterSubCategory = "$($line.meterSubCategory)" MeterName = "$($line.meterName)" Quantity = $qty UnitPrice = $price UnitOfMeasure = "$($line.unitOfMeasure)" ChargeType = "$($line.chargeType)" PricingModel = "$($line.pricingModel)" ReservationId = "$($line.reservationId)" PublisherType = "$($line.publisherType)" PublisherName = "$($line.publisherName)" Tags = "$($line.tags)" }) } } } # did every day we asked for actually come back $requested = @() for ($d = $startDate; $d -le $endDate; $d = $d.AddDays(1)) { $requested += $d.ToString('yyyy-MM-dd') } $missing = @($requested | Where-Object { -not $datesSeen.ContainsKey($_) }) if ($rows.Count -eq 0) { Write-Warning "Cost Details returned no resource cost for $startStr .. $endStr." } elseif ($missing) { Write-Warning "No cost data yet for $($missing -join ', '). Azure lags 8-48 hours so those days read as zero - use -offsetDays 2 for a settled day." } $result = New-Object psobject $result | Add-Member -MemberType NoteProperty -Name Scope -Value $scope $result | Add-Member -MemberType NoteProperty -Name StartDate -Value $startStr $result | Add-Member -MemberType NoteProperty -Name EndDate -Value $endStr $result | Add-Member -MemberType NoteProperty -Name Days -Value $days $result | Add-Member -MemberType NoteProperty -Name Metric -Value $metric $result | Add-Member -MemberType NoteProperty -Name CostColumn -Value $costColUsed $result | Add-Member -MemberType NoteProperty -Name DatesWithData -Value @($datesSeen.Keys | Sort-Object) $result | Add-Member -MemberType NoteProperty -Name MissingDates -Value $missing $result | Add-Member -MemberType NoteProperty -Name Rows -Value $rows $script:SilkTCOCostDetailsCache[$cacheKey] = $result return $result } |