KnowIT.AzureCosts.psm1
|
#region === Public functions === ### Source file: 'Clear-KnowITAzCostsCache.ps1' ### function Clear-KnowITAzCostsCache { <# .SYNOPSIS Borra el cache local de detalle de costos e inventario. .DESCRIPTION El cache está en ~/.knowit/KnowIT.AzureCosts, una carpeta por suscripcion. Sin parametros borra todo el cache. Borrar el detalle de un mes obliga a volver a consultar la Cost Details API, que Microsoft pide no usar mas de una vez al dia por suscripcion. .PARAMETER SubscriptionId Solo el cache de estas suscripciones. .PARAMETER Month Solo el detalle de costos de estos meses (yyyy-MM), de todas las metricas. El inventario no se toca. Alias: -Period. .EXAMPLE Clear-KnowITAzCostsCache -SubscriptionId $sub -Month 2026-09 .EXAMPLE Clear-KnowITAzCostsCache -WhatIf #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Position = 0, ValueFromPipelineByPropertyName)] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [Alias('Id')] [string[]]$SubscriptionId, [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')] [Alias('Period')] [string[]]$Month ) begin { $subscriptions = [System.Collections.Generic.List[string]]::new() } process { foreach($id in $SubscriptionId) { $subscriptions.Add($id) } } end { try { # Update-CallerPreference usa Set-Variable, que tambien obedece -WhatIf e imprimiria # un "What if" por cada preferencia. $whatIf = $WhatIfPreference $WhatIfPreference = $false Update-CallerPreference $PSCmdlet -Skip Confirm, WhatIf $WhatIfPreference = $whatIf if(!(Test-Path $script:CacheRoot)) { Write-Verbose "No hay cache en $script:CacheRoot" return } $folders = $subscriptions.Count ? @($subscriptions | ForEach-Object { GetCacheFolder $_ } | Where-Object { Test-Path $_ }) : @(Get-ChildItem $script:CacheRoot -Directory | ForEach-Object FullName) foreach($folder in $folders) { if($Month) { $files = @(Get-ChildItem $folder -File | Where-Object { $name = $_.Name @($Month | Where-Object { $name -like "cost-*-$_.json" -or $name -like "cost-*-$_-part*.csv" }).Count }) foreach($file in $files) { if($PSCmdlet.ShouldProcess($file.FullName, 'Borrar del cache')) { Remove-Item $file.FullName -Force } } } elseif($PSCmdlet.ShouldProcess($folder, 'Borrar cache de la suscripcion')) { Remove-Item $folder -Recurse -Force } } } catch { $PSCmdlet.WriteError($_) } } } ### Source file: 'Export-KnowITAzCostsRaw.ps1' ### function Export-KnowITAzCostsRaw { <# .SYNOPSIS Exporta a CSV el detalle diario por recurso tal como lo entrega la Cost Details API, con los tags expandidos en columnas. .DESCRIPTION Escribe un solo CSV con todas las columnas originales del detalle de costos (una fila por recurso, por dia y por medidor) de las suscripciones y meses indicados, mas una columna por tag. Sin -TagColumn se agregan todos los tags que aparecen en las filas exportadas; al recurso que no tiene un tag se le deja vacio. Por default los tags son los que tenia el recurso el dia del cargo. Con -TagsFromInventory se usan los de hoy para los recursos que siguen existiendo. Usa el mismo cache local que Get-KnowITAzCostsDetail. Devuelve el archivo generado. .PARAMETER Path Ruta del CSV a generar. Si existe, se reemplaza. .PARAMETER Month Uno o varios meses en formato yyyy-MM. Alias: -Period. .PARAMETER StartDate Inicio de un rango de fechas. Con -EndDate, alternativa a -Month. .PARAMETER EndDate Fin del rango de fechas, inclusive. .PARAMETER SubscriptionId Una o varias suscripciones. Acepta la salida de Get-AzSubscription por pipeline. .PARAMETER ResourceGroup Solo las filas de estos resource groups. Acepta comodines. .PARAMETER Untagged Solo las filas sin ningun tag. Excluyente con -ResourceGroup y -Tag. .PARAMETER Tag Solo las filas cuyos tags cumplen todos los pares indicados. Los valores aceptan comodines. .PARAMETER TagColumn Limita las columnas de tags a estos. Si un tag se llama igual que una columna del detalle, la columna se llama tag_<nombre>. .PARAMETER TagsFromInventory Usa los tags de hoy (inventario de Resource Graph) en vez de los del dia del cargo. Los recursos que ya no existen conservan los del cargo. .PARAMETER Metric ActualCost (default) o AmortizedCost. .PARAMETER Tenant Tenant para pedir el token. Sin el se usa el de la sesion de Az. .PARAMETER Force Vuelve a descargar el detalle (y el inventario) aunque esten en cache. .EXAMPLE Export-KnowITAzCostsRaw -Month 2026-08 -SubscriptionId $sub -Path ./costos-2026-08.csv .EXAMPLE Export-KnowITAzCostsRaw -StartDate 2026-07-15 -EndDate 2026-08-14 -SubscriptionId $sub -TagColumn Environment, Owner -Path ./costos.csv #> [CmdletBinding(DefaultParameterSetName = 'Month')] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [string]$Path, [Parameter(Mandatory, Position = 0, ParameterSetName = 'Month')] [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')] [Alias('Period')] [string[]]$Month, [Parameter(Mandatory, ParameterSetName = 'Range')] [datetime]$StartDate, [Parameter(Mandatory, ParameterSetName = 'Range')] [datetime]$EndDate, [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [Alias('Id')] [string[]]$SubscriptionId, [SupportsWildcards()] [string[]]$ResourceGroup, [switch]$Untagged, [hashtable]$Tag, [string[]]$TagColumn, [switch]$TagsFromInventory, [ValidateSet('ActualCost', 'AmortizedCost')] [string]$Metric = 'ActualCost', [ValidateNotNullOrWhiteSpace()] [Alias('TenantId')] [string]$Tenant, [switch]$Force ) begin { $subscriptions = [System.Collections.Generic.List[string]]::new() } process { foreach($id in $SubscriptionId) { $subscriptions.Add($id) } } end { try { Update-CallerPreference $PSCmdlet AssertFilterCombination $PSBoundParameters $periods = @(ResolvePeriod -Month $Month -StartDate ($PSBoundParameters.StartDate) -EndDate ($PSBoundParameters.EndDate)) $unique = @($subscriptions | Sort-Object -Unique) $inventoryIndex = $TagsFromInventory ? (NewInventoryIndex @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force)) : @{} # Primera pasada: filtrar y resolver tags. Las columnas de tags dependen de todas # las filas elegidas, asi que no se puede escribir mientras se lee. $selected = [System.Collections.Generic.List[object]]::new() $headers = [System.Collections.Generic.List[string]]::new() $seenHeaders = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $tagsCache = [System.Collections.Generic.Dictionary[string, hashtable]]::new() foreach($subscription in $unique) { foreach($period in $periods) { $files = @(GetCostDetailFiles -SubscriptionId $subscription -Month $period.Month -Metric $Metric -Tenant $Tenant -Force:$Force) if(!$files) { continue } Write-Verbose "Leyendo detalle de $($period.Month) de $subscription..." $data = ReadCostRows $files foreach($header in $data.Headers) { if($seenHeaders.Add($header)) { $headers.Add($header) } } $map = NewColumnMap $data.Headers foreach($row in $data.Rows) { if($map.Date -and !(TestDateInPeriod (ConvertToUsageDate $row.($map.Date)) $period)) { continue } $resourceId = $map.ResourceId ? "$($row.($map.ResourceId))".Trim() : '' $resource = $resourceId ? $inventoryIndex[$resourceId.ToLowerInvariant()] : $null if($resource) { $tags = $resource.Tags } else { $tagsText = $map.Tags ? "$($row.($map.Tags))" : '' if(!$tagsCache.TryGetValue($tagsText, [ref]$null)) { $tagsCache[$tagsText] = ConvertFromTagsText $tagsText } $tags = $tagsCache[$tagsText] } # No llamarla $resourceGroup: es la misma variable que el parametro -ResourceGroup. $rowResourceGroup = $map.ResourceGroup ? "$($row.($map.ResourceGroup))".Trim() : '' if(!$rowResourceGroup -and $resourceId) { $rowResourceGroup = GetResourceGroupFromId $resourceId } if(TestItemFilter -ResourceGroup $rowResourceGroup -Tags $tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged -TagFilter $Tag) { $selected.Add(@{ Row = $row; Tags = $tags }) } } } } $tagNames = $TagColumn ? $TagColumn : (GetTagNames @($selected | ForEach-Object Tags)) $reserved = [System.Collections.Generic.HashSet[string]]::new([string[]]$headers, [StringComparer]::OrdinalIgnoreCase) $tagColumns = @(foreach($name in $tagNames) { @{ Tag = $name; Column = $reserved.Contains($name) ? "tag_$name" : $name } }) $target = [IO.Path]::GetFullPath($Path, (Get-Location).Path) $null = New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force Write-Verbose "Escribiendo $($selected.Count) filas y $($tagColumns.Count) columnas de tags en $target..." WriteRawCsv -Path $target -Headers $headers -TagColumns $tagColumns -Items $selected Get-Item $target } catch { $PSCmdlet.WriteError($_) } } } ### Source file: 'Get-KnowITAzCostsDetail.ps1' ### function Get-KnowITAzCostsDetail { <# .SYNOPSIS Costo por recurso y por mes de una o varias suscripciones de Azure. .DESCRIPTION Devuelve un objeto por recurso por mes con el costo acumulado del periodo, sus tags y los datos del recurso. Los cargos que no pertenecen a un recurso (Marketplace, soporte, reservas) salen como filas sin ResourceId. Trabaja sobre un cache local: la primera vez que se pide un mes de una suscripcion se descarga el detalle completo con la Cost Details API y los filtros se aplican despues, sin volver a consultar Azure. Microsoft pide no consultar esa API mas de una vez al dia por suscripcion. Los meses cerrados quedan en cache de forma permanente; el mes en curso se renueva cada 24 horas. Tags: si el recurso existe hoy se usan sus tags de hoy, aunque no tenga ninguno: son los que alguien va a corregir. Si ya no existe, los ultimos con valor que tuvo en el periodo. Los pipelines y triggers de Data Factory se suman a su factory. .PARAMETER Month Uno o varios meses en formato yyyy-MM. Alias: -Period. .PARAMETER StartDate Inicio de un rango de fechas. Con -EndDate, alternativa a -Month; los meses que el rango cubre a medias solo suman los dias dentro del rango. .PARAMETER EndDate Fin del rango de fechas, inclusive. .PARAMETER SubscriptionId Una o varias suscripciones. Acepta la salida de Get-AzSubscription por pipeline. .PARAMETER ResourceGroup Solo los recursos de estos resource groups. Acepta comodines. .PARAMETER Untagged Solo los recursos sin ningun tag. Excluyente con -ResourceGroup y -Tag. .PARAMETER Tag Solo los recursos cuyos tags cumplen todos los pares indicados. Los valores aceptan comodines: @{ Environment = 'Prod*'; Role = 'Web' }. .PARAMETER TagColumn Tags que ademas de ir en la propiedad Tags se agregan como propiedades propias, listas para Export-Csv. Si un tag se llama igual que una propiedad existente, la columna se llama tag_<nombre>. .PARAMETER Metric ActualCost (default) o AmortizedCost. .PARAMETER Tenant Tenant para pedir el token. Sin el se usa el de la sesion de Az. .PARAMETER SplitByTagChange Si un recurso cambio de tags dentro del mes, devuelve una fila por cada juego de tags que tuvo, con el costo de esos dias y las propiedades FirstDate y LastDate. Usa los tags historicos del detalle aunque el recurso exista hoy; los dias sin tags forman su propia fila. Los pipelines y triggers de Data Factory siguen los tags que su factory tuvo ese dia. .PARAMETER TagsAsJson Entrega la propiedad Tags como texto JSON compacto en vez de hashtable, lista para Export-Csv: {"Environment":"Production","Owner":"Equipo-FinOps"}. Sin tags queda {}. .PARAMETER Force Vuelve a descargar el detalle y el inventario aunque esten en cache. .EXAMPLE Get-KnowITAzCostsDetail -Month 2026-08 -SubscriptionId $sub -Untagged .EXAMPLE Get-KnowITAzCostsDetail -Period 2026-07, 2026-08 -SubscriptionId $sub -Tag @{ Environment = 'Production' } -TagColumn Role, Owner -TagsAsJson | Export-Csv costos.csv #> [CmdletBinding(DefaultParameterSetName = 'Month')] [OutputType('KnowIT.AzureCosts.CostDetail')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'Month')] [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')] [Alias('Period')] [string[]]$Month, [Parameter(Mandatory, ParameterSetName = 'Range')] [datetime]$StartDate, [Parameter(Mandatory, ParameterSetName = 'Range')] [datetime]$EndDate, [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [Alias('Id')] [string[]]$SubscriptionId, [SupportsWildcards()] [string[]]$ResourceGroup, [switch]$Untagged, [hashtable]$Tag, [string[]]$TagColumn, [switch]$TagsAsJson, [switch]$SplitByTagChange, [ValidateSet('ActualCost', 'AmortizedCost')] [string]$Metric = 'ActualCost', [ValidateNotNullOrWhiteSpace()] [Alias('TenantId')] [string]$Tenant, [switch]$Force ) begin { $subscriptions = [System.Collections.Generic.List[string]]::new() } process { foreach($id in $SubscriptionId) { $subscriptions.Add($id) } } end { try { Update-CallerPreference $PSCmdlet AssertFilterCombination $PSBoundParameters $periods = @(ResolvePeriod -Month $Month -StartDate ($PSBoundParameters.StartDate) -EndDate ($PSBoundParameters.EndDate)) $unique = @($subscriptions | Sort-Object -Unique) $inventory = @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force) $inventoryIndex = NewInventoryIndex $inventory $results = [System.Collections.Generic.List[object]]::new() foreach($subscription in $unique) { foreach($period in $periods) { $files = @(GetCostDetailFiles -SubscriptionId $subscription -Month $period.Month -Metric $Metric -Tenant $Tenant -Force:$Force) if(!$files) { continue } Write-Verbose "Leyendo detalle de $($period.Month) de $subscription..." $data = ReadCostRows $files $items = MergeResourceCost -SubscriptionId $subscription -Period $period -Rows $data.Rows -Headers $data.Headers -InventoryIndex $inventoryIndex -SplitByTagChange:$SplitByTagChange foreach($item in $items) { if(TestItemFilter -ResourceGroup $item.ResourceGroup -Tags $item.Tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged -TagFilter $Tag) { $results.Add($item) } } } } if($TagColumn -and $results.Count) { AddTagColumns $results $TagColumn $results[0].PSObject.Properties.Name } if($TagsAsJson) { ConvertTagsToJson $results } # Por suscripcion y mes, el recurso mas caro primero. $results | Sort-Object SubscriptionId, Month, @{ Expression = 'Cost'; Descending = $true } -Stable } catch { $PSCmdlet.WriteError($_) } } } ### Source file: 'Get-KnowITAzCostsResourceInventory.ps1' ### function Get-KnowITAzCostsResourceInventory { <# .SYNOPSIS Inventario de recursos de Azure con sus tags, desde Azure Resource Graph. .DESCRIPTION Devuelve un objeto por recurso ARM de las suscripciones indicadas, con sus tags de hoy. Complementa al detalle de costos: un NSG o un disco desasociado no generan cargo y no aparecen en el detalle, pero si en el inventario. El inventario se guarda en un cache local por dia; las siguientes llamadas del mismo dia no vuelven a consultar Azure salvo con -Force. .PARAMETER SubscriptionId Suscripciones a inventariar. Sin este parametro se usan todas las suscripciones habilitadas a las que tiene acceso la sesion, incluidas las delegadas por Lighthouse. .PARAMETER ResourceGroup Solo los recursos de estos resource groups. Acepta comodines. Excluyente con -Untagged. .PARAMETER Untagged Solo los recursos sin ningun tag (un tag con valor vacio no cuenta). .PARAMETER Tenant Tenant para pedir el token. Sin el se usa el de la sesion de Az. .PARAMETER TagsAsJson Entrega la propiedad Tags como texto JSON compacto en vez de hashtable, lista para Export-Csv: {"Environment":"Production","Owner":"Equipo-FinOps"}. Sin tags queda {}. .PARAMETER Force Vuelve a consultar Resource Graph aunque el inventario de hoy ya este en cache. .EXAMPLE Get-KnowITAzCostsResourceInventory -SubscriptionId $sub -Untagged .EXAMPLE Get-AzSubscription | Get-KnowITAzCostsResourceInventory -TagsAsJson | Export-Csv inventario.csv #> [CmdletBinding(DefaultParameterSetName = 'All')] [OutputType('KnowIT.AzureCosts.Resource')] param( [Parameter(Position = 0, ValueFromPipelineByPropertyName)] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [Alias('Id')] [string[]]$SubscriptionId, [Parameter(Mandatory, ParameterSetName = 'ResourceGroup')] [SupportsWildcards()] [string[]]$ResourceGroup, [Parameter(Mandatory, ParameterSetName = 'Untagged')] [switch]$Untagged, [ValidateNotNullOrWhiteSpace()] [Alias('TenantId')] [string]$Tenant, [switch]$TagsAsJson, [switch]$Force ) begin { $subscriptions = [System.Collections.Generic.List[string]]::new() } process { foreach($id in $SubscriptionId) { $subscriptions.Add($id) } } end { try { Update-CallerPreference $PSCmdlet if(!$subscriptions.Count) { Write-Verbose 'Sin -SubscriptionId: se usan todas las suscripciones accesibles.' foreach($id in (GetAccessibleSubscriptions $Tenant)) { $subscriptions.Add($id) } } $unique = @($subscriptions | Sort-Object -Unique) $inventory = @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force | Where-Object { TestItemFilter -ResourceGroup $_.ResourceGroup -Tags $_.Tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged }) if($TagsAsJson) { ConvertTagsToJson $inventory } SortOrdinal $inventory { "$($_.SubscriptionId)`0$($_.ResourceGroup)`0$($_.Name)".ToLowerInvariant() } } catch { $PSCmdlet.WriteError($_) } } } #endregion #region === Private functions === ### Source file: 'Aggregation.ps1' ### function MergeResourceCost { # Suma el detalle diario de un mes en una fila por recurso (o por recurso y juego de tags # con -SplitByTagChange). Devuelve los objetos sin filtrar; los filtros se aplican despues # sobre los tags efectivos. param( [string]$SubscriptionId, $Period, [System.Collections.Generic.List[object]]$Rows, [string[]]$Headers, [hashtable]$InventoryIndex, [switch]$SplitByTagChange ) $map = NewColumnMap $Headers $column = { param($row, $field) $name = $map[$field]; $name ? "$($row.$name)".Trim() : '' } if(!$map.Cost) { Write-Warning "No se encontro ninguna columna de costo reconocible en el detalle de $($Period.Month). Candidatas: $($script:ColumnCandidates.Cost -join ', ')" } # Las llaves son ResourceId en minusculas; los sub-recursos de Data Factory van a la llave # de su padre. Por llave se guarda la historia de tags con valor, por fecha: los propios # del recurso y, aparte, los de sus hijos, que solo cuentan si el padre no trae ninguno. # $ownDayTags guarda ademas los dias sin tags: con split, los hijos siguen los tags que su # padre tuvo ese dia, vacios o no. $costByKey = [ordered]@{} $ownHistory = @{} $childHistory = @{} $ownDayTags = @{} $sampleByKey = @{} $sampleFromChild = [System.Collections.Generic.HashSet[string]]::new() $records = [System.Collections.Generic.List[object]]::new() # El mismo texto de tags se repite en miles de filas: parsearlo una vez. $tagsCache = [System.Collections.Generic.Dictionary[string, hashtable]]::new() $currency = '' foreach($row in $Rows) { $date = ConvertToUsageDate (& $column $row 'Date') if(!(TestDateInPeriod $date $Period)) { continue } $idNorm = (& $column $row 'ResourceId').ToLowerInvariant() $parent = $idNorm ? (GetParentResourceId $idNorm) : $null $cost = ConvertToCostNumber (& $column $row 'Cost') if(!$idNorm) { # Cargos sin ResourceId (soporte, algunos Marketplace) se agrupan por servicio y # tipo de cargo para no perderlos del total. $key = "`0$(& $column $row 'Service')`0$(& $column $row 'Charge')" } else { $key = $parent ?? $idNorm } $costByKey[$key] = ($costByKey[$key] ?? 0.0) + $cost # La muestra da nombre y servicio cuando el recurso no esta en el inventario: la del # propio padre gana sobre la de un hijo. if(!$sampleByKey.ContainsKey($key) -or (!$parent -and $sampleFromChild.Contains($key))) { $sampleByKey[$key] = $row if($parent) { $null = $sampleFromChild.Add($key) } else { $null = $sampleFromChild.Remove($key) } } $tagsText = & $column $row 'Tags' if(!$tagsCache.TryGetValue($tagsText, [ref]$null)) { $tagsCache[$tagsText] = ConvertFromTagsText $tagsText } $tags = $tagsCache[$tagsText] if(!(TestUntagged $tags)) { AddTagHistory ($parent ? $childHistory : $ownHistory) $key $date $tags } if($SplitByTagChange) { if(!$parent) { AddTagHistory $ownDayTags $key $date $tags } $records.Add(@{ Key = $key; Date = $date; Cost = $cost; Tags = $tags; IsChild = [bool]$parent }) } if(!$currency) { $currency = & $column $row 'Currency' } } if(!$SplitByTagChange) { foreach($key in $costByKey.Keys) { $resource = GetInventoryResource $InventoryIndex $key # Si el recurso existe, sus tags de hoy, aunque no tenga ninguno: son los que alguien # va a corregir. Si ya no existe, los ultimos con valor que tuvo en el periodo (por # fecha, no por orden de fila: la API no entrega las filas ordenadas). $tags = $resource ? $resource.Tags : ((GetLatestTags $ownHistory[$key]) ?? (GetLatestTags $childHistory[$key]) ?? @{}) NewCostDetailItem -Key $key -Sample $sampleByKey[$key] -Resource $resource -Cost $costByKey[$key] -Tags $tags ` -Map $map -Month $Period.Month -SubscriptionId $SubscriptionId -Currency $currency -FromChild $sampleFromChild.Contains($key) } return } # Con split, cada fila lleva los tags que trae ese dia, tal cual: una fila sin tags es un # juego mas y genera su propio segmento. Los hijos de Data Factory siguen los tags que su # padre tuvo ese dia; solo si el padre no tiene filas ese dia se usan los del hijo. $segments = [ordered]@{} foreach($record in $records) { $key = $record.Key $tags = $record.Tags if($record.IsChild) { $parentTags = $null if($ownDayTags[$key]?.TryGetValue(($record.Date ?? [datetime]::MinValue), [ref]$parentTags)) { $tags = $parentTags } } $segmentKey = "$key`0$(GetTagSetKey $tags)" $segment = $segments[$segmentKey] if(!$segment) { $segment = @{ Key = $key; Tags = $tags; Cost = 0.0; FirstDate = $record.Date; LastDate = $record.Date } $segments[$segmentKey] = $segment } $segment.Cost += $record.Cost if($record.Date -and (!$segment.FirstDate -or $record.Date -lt $segment.FirstDate)) { $segment.FirstDate = $record.Date } if($record.Date -and (!$segment.LastDate -or $record.Date -gt $segment.LastDate)) { $segment.LastDate = $record.Date } } foreach($segment in $segments.Values) { $resource = GetInventoryResource $InventoryIndex $segment.Key $item = NewCostDetailItem -Key $segment.Key -Sample $sampleByKey[$segment.Key] -Resource $resource -Cost $segment.Cost -Tags $segment.Tags ` -Map $map -Month $Period.Month -SubscriptionId $SubscriptionId -Currency $currency -FromChild $sampleFromChild.Contains($segment.Key) $item | Add-Member -NotePropertyName FirstDate -NotePropertyValue $segment.FirstDate $item | Add-Member -NotePropertyName LastDate -NotePropertyValue $segment.LastDate $item } } function GetInventoryResource ([hashtable]$InventoryIndex, [string]$Key) { # Ordinal: con comparacion cultural .NET ignora el caracter nulo y todo "empieza" con el. if($Key.StartsWith("`0", [StringComparison]::Ordinal)) { return $null } $InventoryIndex[$Key] } function AddTagHistory ([hashtable]$History, [string]$Key, $Date, [hashtable]$Tags) { # Un juego de tags por dia; si un dia trae dos distintos gana el ultimo leido (con valor, # si alguno lo tiene: una fila de ajuste vacia no borra los tags del dia). if(!$History.ContainsKey($Key)) { $History[$Key] = [System.Collections.Generic.SortedDictionary[datetime, hashtable]]::new() } $day = $Date ?? [datetime]::MinValue $previous = $null if($History[$Key].TryGetValue($day, [ref]$previous) -and (TestUntagged $Tags) -and !(TestUntagged $previous)) { return } $History[$Key][$day] = $Tags } function GetLatestTags ($History) { if(!$History -or !$History.Count) { return $null } $latest = $null foreach($entry in $History.GetEnumerator()) { $latest = $entry.Value } $latest } function GetTagSetKey ([hashtable]$Tags) { # Identifica un juego de tags sin importar el orden de las llaves ni sus mayusculas. $parts = foreach($name in (SortOrdinal @($Tags.Keys | ForEach-Object { $_.ToLowerInvariant() }))) { "$name=$($Tags[$name])" } $parts -join "`0" } function NewCostDetailItem { param( [string]$Key, $Sample, $Resource, [double]$Cost, [hashtable]$Tags, [hashtable]$Map, [string]$Month, [string]$SubscriptionId, [string]$Currency, # La muestra salio de un sub-recurso: su nombre es el del hijo, no el del recurso. [bool]$FromChild ) $isCharge = $Key.StartsWith("`0", [StringComparison]::Ordinal) $column = { param($row, $field) $name = $Map[$field]; $name ? "$($row.$name)".Trim() : '' } if($Resource) { $resourceId = $Resource.ResourceId $name = $Resource.Name $type = $Resource.Type $resourceGroup = $Resource.ResourceGroup $location = $Resource.Location } elseif($isCharge) { $resourceId = $null $name = $null $type = $null $resourceGroup = $null $location = & $column $Sample 'Location' } else { $sampleId = & $column $Sample 'ResourceId' $resourceId = $sampleId -and $sampleId.ToLowerInvariant() -eq $Key ? $sampleId : $Key $name = $FromChild ? '' : (& $column $Sample 'ResourceName') if(!$name) { $name = $Key.Substring($Key.LastIndexOf('/') + 1) } $type = & $column $Sample 'ResourceType' $resourceGroup = & $column $Sample 'ResourceGroup' if(!$resourceGroup) { $resourceGroup = GetResourceGroupFromId $Key } $location = & $column $Sample 'Location' } [PSCustomObject]@{ PSTypeName = 'KnowIT.AzureCosts.CostDetail' Month = $Month SubscriptionId = $SubscriptionId ResourceId = $resourceId ResourceName = $name ResourceType = $type ResourceGroup = $resourceGroup Location = $location ServiceName = & $column $Sample 'Service' ChargeType = $isCharge ? (& $column $Sample 'Charge') : $null Cost = $Cost Currency = $Currency ExistsInInventory = [bool]$Resource Tags = $Tags } } ### Source file: 'AzManagement.ps1' ### function InvokeAzManagement { # Llamada a Azure Resource Manager. Devuelve el valor (o el objeto de respuesta completo # con -DetailResponse) y lanza excepcion si Azure responde con error: las funciones # publicas la atrapan y la emiten con $PSCmdlet.WriteError. param( # Ruta relativa a https://management.azure.com, o URL absoluta (p. ej. el # encabezado Location de una operacion asincrona o un nextLink). [Parameter(Mandatory, Position = 0)] [string]$Path, [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method = 'GET', [object]$Body, [string]$Tenant, # Devuelve Success, StatusCode, Headers, Value y Content (el cuerpo sin desenvolver) # en vez de solo el valor, y no lanza excepcion por error. Hace falta para las # operaciones que responden 202 y para paginar con nextLink. [switch]$DetailResponse ) $uri = $Path -match '^https://' ? $Path : "$script:ArmEndpoint/$($Path.TrimStart('/'))" $restParams = @{ Method = $Method Uri = $uri Headers = @{ Authorization = "Bearer $(GetArmToken $Tenant)" } Body = $Body ? (ConvertTo-Json $Body -Depth 6) : $null ContentType = 'application/json' Verbose = $false } Write-Verbose "Request ARM: $Method $uri" if($Body) { Write-Debug "Body: $($restParams.Body)" } $result = Invoke-RestMethod @restParams -SkipHttpErrorCheck -StatusCodeVariable statusCode -ResponseHeadersVariable headers if($statusCode -lt 400) { Write-Verbose "Response ARM: [$statusCode] $([Net.HttpStatusCode]$statusCode)" $response = [PSCustomObject]@{ PSTypeName = 'KnowIT.AzureCosts.Response' Success = $true StatusCode = $statusCode Headers = $headers Value = $result.value ?? $result Content = $result } } else { Write-Debug 'Procesando error en la respuesta del API:' Write-Debug (ConvertTo-Json $result -Depth 8) $response = [PSCustomObject]@{ PSTypeName = 'KnowIT.AzureCosts.Response' Success = $false StatusCode = $statusCode Headers = $headers Content = $result Error = [ordered]@{ code = $result.error.code ?? "$([Net.HttpStatusCode]$statusCode)" message = $result.error.message ?? "$result" } } Write-Verbose "Response ARM: [$statusCode] $($response.Error.code) - $($response.Error.message)" } if($DetailResponse) { return $response } if(!$response.Success) { throw (FormatArmError $response) } $response.Value } ### Source file: 'Cache.ps1' ### # Cache local por usuario en ~/.knowit/KnowIT.AzureCosts: sobrevive reinicios y usuario # quien mantiene el suyo. Estructura: # <CacheRoot>/<suscripcion>/inventory-<yyyy-MM-dd>.json un archivo por dia # <CacheRoot>/<suscripcion>/cost-<metrica>-<yyyy-MM>.json manifiesto del mes # <CacheRoot>/<suscripcion>/cost-<metrica>-<yyyy-MM>-partN.csv detalle tal cual de la API # El manifiesto se escribe al final: un CSV sin manifiesto es una descarga interrumpida y # no cuenta. # Un mes se considera definitivo si se descargo al menos estos dias despues de cerrar: antes # de eso Azure todavia consolida cargos del mes. $script:CostFinalAfterDays = 5 # Mes en curso o recien cerrado: se vuelve a pedir pasado este tiempo. Microsoft pide no # consultar la Cost Details API mas de una vez al dia por suscripcion. $script:CostMaxAge = [timespan]::FromHours(24) function GetCacheFolder ([string]$SubscriptionId) { Join-Path $script:CacheRoot $SubscriptionId.ToLowerInvariant() } function GetCostCacheBaseName ([string]$Metric, [string]$Month) { "cost-$($Metric.ToLowerInvariant())-$Month" } function ReadCostManifest ([string]$SubscriptionId, [string]$Metric, [string]$Month) { $path = Join-Path (GetCacheFolder $SubscriptionId) "$(GetCostCacheBaseName $Metric $Month).json" if(!(Test-Path $path)) { return $null } try { Get-Content $path -Raw | ConvertFrom-Json -ErrorAction Stop } catch { # Un manifiesto corrupto equivale a no tener cache: se vuelve a descargar. Write-Verbose "Manifiesto ilegible, se ignora: $path" $null } } function TestCostCacheFresh ($Manifest, [string]$Month) { if(!$Manifest) { return $false } $downloadedAt = ([datetime]$Manifest.downloadedAt).ToUniversalTime() $first = [datetime]::ParseExact("$Month-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture) $finalFrom = $first.AddMonths(1).AddDays($script:CostFinalAfterDays) if($downloadedAt -ge $finalFrom) { return $true } return ([datetime]::UtcNow - $downloadedAt) -lt $script:CostMaxAge } function WriteCacheFile ([string]$Path, [string]$Content) { # Escribir a un temporal y mover: si el proceso se interrumpe no queda un archivo a # medias con el nombre bueno. $folder = Split-Path $Path -Parent $null = New-Item -ItemType Directory -Path $folder -Force $temp = "$Path.tmp" Set-Content -Path $temp -Value $Content -Encoding utf8 -NoNewline Move-Item -Path $temp -Destination $Path -Force } ### Source file: 'Columns.ps1' ### # Orden = preferencia. FOCUS primero, esquema legacy (ActualCost/AmortizedCost) despues. # La Cost Details API entrega el legacy con nombres en camelCase: la busqueda no distingue # mayusculas. $script:ColumnCandidates = [ordered]@{ ResourceId = 'ResourceId', 'InstanceId', 'InstanceName' ResourceName = , 'ResourceName' ResourceType = 'ResourceType', 'ConsumedService', 'MeterCategory' ResourceGroup = 'x_ResourceGroupName', 'ResourceGroup', 'ResourceGroupName' Service = 'ServiceName', 'MeterCategory', 'ConsumedService' Charge = 'ChargeCategory', 'ChargeType' Location = 'RegionName', 'RegionId', 'ResourceLocation', 'MeterRegion' Tags = , 'Tags' # EffectiveCost es el amortizado en FOCUS; para atribucion mensual es el correcto. Cost = 'EffectiveCost', 'BilledCost', 'CostInBillingCurrency', 'Cost', 'PreTaxCost', 'CostInUSD' Currency = 'BillingCurrency', 'BillingCurrencyCode', 'Currency' Date = 'ChargePeriodStart', 'Date', 'UsageDate' } # La Cost Details API entrega MM/dd/yyyy; FOCUS, ISO 8601 con zona. $script:DateFormats = [string[]]@('MM/dd/yyyy', 'M/d/yyyy', 'yyyy-MM-dd', 'yyyy-MM-ddTHH:mm:ssK', 'yyyy-MM-ddTHH:mm:ss') function NewColumnMap ([string[]]$Headers) { # Hashtable de PowerShell: llaves sin distinguir mayusculas. $index = @{} foreach($header in $Headers) { if(!$index.ContainsKey($header)) { $index[$header] = $header } } $map = @{} foreach($field in $script:ColumnCandidates.Keys) { $map[$field] = $null foreach($candidate in $script:ColumnCandidates[$field]) { if($index.ContainsKey($candidate)) { $map[$field] = $index[$candidate] break } } } $map } function ConvertFromTagsText ([string]$Text) { # FOCUS entrega Tags como JSON: {"Owner":"Equipo-FinOps"} # El export legacy a veces lo entrega SIN llaves: "Owner": "Equipo-FinOps", "Env": "prod" # Esta diferencia es la causa clasica de reportar todo como sin etiquetar. $tags = @{} if([string]::IsNullOrWhiteSpace($Text)) { return $tags } $json = $Text.Trim() if(!$json.StartsWith('{')) { $json = "{$json}" } try { $data = ConvertFrom-Json $json -AsHashtable -ErrorAction Stop } catch { return $tags } if($data -isnot [System.Collections.IDictionary]) { return $tags } foreach($key in $data.Keys) { $tags[[string]$key] = "$($data[$key])" } return $tags } function ConvertToCostNumber ([string]$Text) { # InvariantCulture: el CSV usa punto decimal y a veces notacion cientifica, sin # importar la configuracion regional de la maquina. $value = 0.0 if([double]::TryParse($Text, [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$value)) { return $value } return 0.0 } function ConvertToUsageDate ([string]$Text) { # AdjustToUniversal: sin el, '2026-08-01T00:00:00Z' en Mexico se vuelve 31 de julio. $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal $value = [datetime]::MinValue if([datetime]::TryParseExact($Text.Trim(), $script:DateFormats, [cultureinfo]::InvariantCulture, $styles, [ref]$value)) { return $value.Date } return $null } function ReadCostRows ([string[]]$Path) { # Devuelve @{ Rows; Headers }. Los parts de un mismo reporte comparten esquema; entre # meses podria cambiar, asi que los encabezados son la union en orden de aparicion. $rows = [System.Collections.Generic.List[object]]::new() $headers = [System.Collections.Generic.List[string]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach($file in $Path) { $batch = Import-Csv -Path $file -Encoding utf8 if($batch) { foreach($name in $batch[0].PSObject.Properties.Name) { if($seen.Add($name)) { $headers.Add($name) } } } foreach($row in $batch) { $rows.Add($row) } } @{ Rows = $rows; Headers = @($headers) } } ### Source file: 'Completers.ps1' ### # Autocompletado de -Tag y -TagColumn a partir de los tags reales de la suscripcion. Sale # del inventario del dia: si no esta en cache se consulta Resource Graph, que es barato y no # tiene el limite de la Cost Details API. Cualquier error se calla: un autocompletado que # falla no debe ensuciar la consola. function GetCompletionTags ([hashtable]$BoundParameters) { $subscriptions = @($BoundParameters.SubscriptionId | Where-Object { $_ }) if(!$subscriptions) { return @() } try { $params = @{ SubscriptionId = $subscriptions; ErrorAction = 'Stop' } if($BoundParameters.Tenant) { $params.Tenant = $BoundParameters.Tenant } @((GetInventory @params).Tags) } catch { @() } } function NewQuotedWord ([string]$Text) { $Text -match '^[\w.-]+$' ? $Text : "'$($Text -replace "'", "''")'" } function CompleteTagName ([hashtable]$BoundParameters, [string]$WordToComplete) { $word = $WordToComplete.Trim("'", '"') foreach($name in (GetTagNames (GetCompletionTags $BoundParameters))) { if($name -like "$word*") { [System.Management.Automation.CompletionResult]::new((NewQuotedWord $name), $name, 'ParameterValue', $name) } } } function CompleteTagFilter ([hashtable]$BoundParameters, [string]$WordToComplete) { # -Tag es un hashtable: se ofrece cada par tag=valor existente ya escrito como hashtable. # Lo escrito se compara contra el nombre o el valor. $word = $WordToComplete.Trim('@', '{', "'", '"', ' ') $pairs = @{} foreach($tags in (GetCompletionTags $BoundParameters)) { foreach($name in $tags.Keys) { $value = $tags[$name] if([string]::IsNullOrWhiteSpace($value)) { continue } $pairs["$name`0$value"] = @{ Name = $name; Value = $value } } } foreach($key in (SortOrdinal @($pairs.Keys))) { $pair = $pairs[$key] if($pair.Name -like "$word*" -or $pair.Value -like "$word*") { $text = "@{ $(NewQuotedWord $pair.Name) = '$($pair.Value -replace "'", "''")' }" [System.Management.Automation.CompletionResult]::new($text, "$($pair.Name)=$($pair.Value)", 'ParameterValue', $text) } } } # Se registran al cargar el modulo y no con [ArgumentCompleter()] en el parametro: en el # modulo compilado el scriptblock del atributo no queda ligado al modulo y no ve las # funciones privadas. Este si, porque se crea aqui dentro. $script:TagCompletionCommands = 'Get-KnowITAzCostsDetail', 'Export-KnowITAzCostsRaw' Register-ArgumentCompleter -CommandName $script:TagCompletionCommands -ParameterName TagColumn -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $boundParameters) CompleteTagName $boundParameters $wordToComplete } Register-ArgumentCompleter -CommandName $script:TagCompletionCommands -ParameterName Tag -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $boundParameters) CompleteTagFilter $boundParameters $wordToComplete } ### Source file: 'CostDetail.ps1' ### function GetCostDetailFiles { # Devuelve los CSV del mes desde el cache y los descarga si faltan o ya caducaron. Un mes # sin datos devuelve una lista vacia (tambien queda en cache para no volver a preguntar). param( [string]$SubscriptionId, [string]$Month, [string]$Metric, [string]$Tenant, [switch]$Force ) $manifest = ReadCostManifest $SubscriptionId $Metric $Month if($Force -or !(TestCostCacheFresh $manifest $Month)) { $manifest = SaveCostDetailReport -SubscriptionId $SubscriptionId -Month $Month -Metric $Metric -Tenant $Tenant } else { Write-Verbose "Detalle de $Month ($Metric) de $SubscriptionId desde cache, descargado $($manifest.downloadedAt)" } if($manifest.status -eq 'NoDataFound') { Write-Warning "Azure no tiene datos de costo de $Month para la suscripcion $SubscriptionId." return } $folder = GetCacheFolder $SubscriptionId foreach($name in $manifest.files) { Join-Path $folder $name } } function SaveCostDetailReport { # Genera el detalle de un mes con la Cost Details API y lo guarda en cache. No crea # recursos en Azure: Microsoft genera el CSV en su storage y devuelve URLs con SAS. # Limites: un mes por reporte, maximo 13 meses de antiguedad, solo ActualCost o # AmortizedCost (nunca FOCUS) y solo scope de suscripcion. param( [string]$SubscriptionId, [string]$Month, [string]$Metric, [string]$Tenant, [int]$TimeoutMinutes = 30 ) $first = [datetime]::ParseExact("$Month-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture) $last = $first.AddMonths(1).AddDays(-1) # El mes en curso solo se puede pedir hasta hoy. $today = [datetime]::UtcNow.Date if($last -gt $today) { $last = $today } $apiParams = @{ DetailResponse = $true } if($Tenant) { $apiParams.Tenant = $Tenant } Write-Verbose "Solicitando detalle de $Month ($($first.ToString('yyyy-MM-dd')) a $($last.ToString('yyyy-MM-dd'))), metrica $Metric, suscripcion $SubscriptionId..." $response = InvokeAzManagement @apiParams -Method POST ` -Path "subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/generateCostDetailsReport?api-version=2024-08-01" ` -Body @{ metric = $Metric timePeriod = @{ start = $first.ToString('yyyy-MM-dd'); end = $last.ToString('yyyy-MM-dd') } } if(!$response.Success) { throw (FormatArmError $response) } # 200 inmediato es posible con datasets chicos; 202 + Location es el camino normal. if($response.StatusCode -eq 202) { $location = "$($response.Headers.Location)" if(!$location) { throw 'Azure devolvio 202 sin encabezado Location.' } $retryAfter = "$($response.Headers.'Retry-After')" $wait = $retryAfter ? [int]$retryAfter : 20 $deadline = (Get-Date).AddMinutes($TimeoutMinutes) $attempt = 0 do { if((Get-Date) -gt $deadline) { throw "El reporte no termino de generarse en $TimeoutMinutes minutos." } $wait = [math]::Clamp($wait, 5, 60) $attempt++ Write-Verbose " generando... (intento $attempt, siguiente consulta en ${wait}s)" Start-Sleep -Seconds $wait $response = InvokeAzManagement @apiParams -Path $location if(!$response.Success) { throw (FormatArmError $response) } if($response.Headers.'Retry-After') { $wait = [int]"$($response.Headers.'Retry-After')" } } while($response.StatusCode -ne 200) } $report = $response.Value $folder = GetCacheFolder $SubscriptionId $baseName = GetCostCacheBaseName $Metric $Month $manifest = [ordered]@{ subscriptionId = $SubscriptionId month = $Month metric = $Metric status = 'Completed' downloadedAt = [datetime]::UtcNow.ToString('o') files = @() } switch ($report.status) { 'NoDataFound' { $manifest.status = 'NoDataFound' } { $_ -and $_ -ne 'Completed' } { throw "La generacion del reporte termino en estado '$_'." } default { $blobs = @($report.manifest.blobs) if(!$blobs) { throw "El manifiesto no trae blobs: $(ConvertTo-Json $report -Depth 4 -Compress)" } Write-Verbose ("Manifiesto listo: {0} archivo(s), {1:N0} KB" -f $blobs.Count, ($report.manifest.byteCount / 1KB)) # Los parts viejos se borran antes: un mes que antes traia 3 parts y ahora 2 no # debe arrastrar el tercero. $null = New-Item -ItemType Directory -Path $folder -Force Get-ChildItem $folder -Filter "$baseName-part*.csv" | Remove-Item -Force for($i = 0; $i -lt $blobs.Count; $i++) { $name = "$baseName-part$i.csv" $target = Join-Path $folder $name Write-Verbose " descargando parte $($i + 1)/$($blobs.Count)..." # blobLink ya trae SAS: se descarga sin encabezado de autorizacion. Invoke-WebRequest -Uri $blobs[$i].blobLink -OutFile "$target.tmp" -Verbose:$false Move-Item "$target.tmp" $target -Force $manifest.files += $name } } } WriteCacheFile (Join-Path $folder "$baseName.json") (ConvertTo-Json $manifest) [PSCustomObject]$manifest } ### Source file: 'Errors.ps1' ### function FormatArmError ($Response) { $detail = "[$($Response.StatusCode)] $($Response.Error.code): $($Response.Error.message)" switch ($Response.StatusCode) { 403 { "Azure respondio 403. Posibles causas:`n" + " - La identidad no tiene Cost Management Reader (o Contributor) sobre la suscripcion`n" + " - En suscripciones CSP: el partner no tiene activada la politica de cost visibility`n" + $detail } 429 { "Azure respondio 429 (rate limit). La Cost Details API esta pensada para consultas " + "poco frecuentes (Microsoft pide no mas de una al dia por suscripcion). Si ese mes ya " + "estaba en cache, vuelve a ejecutar el comando sin -Force.`n" + $detail } default { $detail } } } ### Source file: 'Inventory.ps1' ### function GetInventory { # Inventario de Resource Graph de las suscripciones pedidas, desde el cache del dia. Las # que no estan en cache se consultan juntas en una sola llamada (con paginacion) y se # guardan por separado. param( [string[]]$SubscriptionId, [string]$Tenant, [switch]$Force ) $today = [datetime]::Now.ToString('yyyy-MM-dd') $missing = [System.Collections.Generic.List[string]]::new() foreach($id in $SubscriptionId) { $path = Join-Path (GetCacheFolder $id) "inventory-$today.json" if($Force -or !(Test-Path $path)) { $missing.Add($id) } } if($missing.Count) { $bySubscription = @{} foreach($id in $missing) { $bySubscription[$id.ToLowerInvariant()] = [System.Collections.Generic.List[object]]::new() } foreach($item in (QueryResourceGraph -SubscriptionId $missing -Tenant $Tenant)) { $bySubscription["$($item.subscriptionId)".ToLowerInvariant()]?.Add($item) } foreach($id in $missing) { $folder = GetCacheFolder $id $items = $bySubscription[$id.ToLowerInvariant()] # Una suscripcion sin recursos tambien se guarda: el arreglo vacio es la respuesta. WriteCacheFile (Join-Path $folder "inventory-$today.json") (ConvertTo-Json -InputObject @($items) -Depth 6) # Solo sirve el inventario de hoy; los de dias anteriores se descartan. Get-ChildItem $folder -Filter 'inventory-*.json' | Where-Object Name -ne "inventory-$today.json" | Remove-Item -Force Write-Verbose "Inventario de $id guardado en cache: $($items.Count) recursos" } } foreach($id in $SubscriptionId) { $path = Join-Path (GetCacheFolder $id) "inventory-$today.json" if(!$missing.Contains($id)) { Write-Verbose "Inventario de $id desde cache ($today)" } foreach($item in (Get-Content $path -Raw | ConvertFrom-Json -NoEnumerate)) { NewResourceItem $item } } } function QueryResourceGraph { param( [string[]]$SubscriptionId, [string]$Tenant ) $apiParams = @{} if($Tenant) { $apiParams.Tenant = $Tenant } $skipToken = $null $total = 0 do { $options = @{ resultFormat = 'objectArray'; '$top' = 1000 } if($skipToken) { $options.'$skipToken' = $skipToken } $response = InvokeAzManagement @apiParams -Method POST ` -Path 'providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' ` -Body @{ subscriptions = @($SubscriptionId) query = 'resources | project id, name, type, resourceGroup, location, subscriptionId, tags' options = $options } # ARG usa .data y no .value, asi que llega el cuerpo completo. foreach($item in $response.data) { $item } $total += @($response.data).Count $skipToken = $response.'$skipToken' Write-Verbose " Resource Graph: $total recursos acumulados" } while($skipToken) } function GetAccessibleSubscriptions ([string]$Tenant) { # Incluye las suscripciones delegadas por Lighthouse: ARM las lista igual que las propias. $apiParams = @{ DetailResponse = $true } if($Tenant) { $apiParams.Tenant = $Tenant } $path = 'subscriptions?api-version=2022-12-01' while($path) { $response = InvokeAzManagement @apiParams -Path $path if(!$response.Success) { throw (FormatArmError $response) } foreach($subscription in @($response.Content.value)) { if($subscription.state -eq 'Enabled') { $subscription.subscriptionId } } $path = $response.Content.nextLink } } ### Source file: 'KnowIT.ModuleHelpers.ps1' ### function Update-CallerPreference { # https://devblogs.microsoft.com/scripting/weekend-scripter-access-powershell-preference-variables/ param( [ValidateNotNull()] [PSTypeName('System.Management.Automation.PSScriptCmdlet')]$ScriptCmdlet = (Get-Variable PSCmdlet -Scope 1 -ValueOnly), [ValidateSet('ErrorAction', 'Warning', 'Verbose', 'Debug', 'Information', 'Progress', 'Confirm', 'WhatIf')] [string[]]$Skip ) $commonParameters = 'ErrorAction', 'Warning', 'Verbose', 'Debug', 'Information', 'Progress', 'Confirm', 'WhatIf' $invocation = $ScriptCmdlet.MyInvocation $commandDebug = $invocation.BoundParameters.ContainsKey('Debug') Write-Debug "Updating [$($invocation.MyCommand)] Preference variables:" -Debug:$commandDebug foreach($p in $commonParameters) { if($invocation.BoundParameters.ContainsKey($p)) { continue } $var = "${p}Preference" if($p -eq 'ErrorAction') { $val = 'Stop' $scope = 'Forced' } elseif($p -in $Skip) { $val = Get-Variable -Scope Global -Name $var -ValueOnly $scope = 'Global' } else { $val = $ScriptCmdlet.GetVariableValue($var) $scope = 'Caller' } Write-Debug " (From $scope scope) $var = $val " -Debug:$commandDebug Set-Variable -Scope 1 -Name $var -Value $val } } function Map-Object { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Internal functions')] param([scriptblock]$ScriptBlock) begin { $code = "& { process { $ScriptBlock } }" $pipeline = [scriptblock]::Create($code).GetSteppablePipeline() $pipeline.Begin($true) } process { $pipeline.Process($_) } end { $pipeline.End() } } ### Source file: 'Periods.ps1' ### function ResolvePeriod { # Convierte -Month o -StartDate/-EndDate en la lista de meses que hay que tener en # cache. El cache siempre guarda meses completos; Start/End recortan despues las filas # cuando el rango no cubre el mes entero. param( [string[]]$Month, [Nullable[datetime]]$StartDate, [Nullable[datetime]]$EndDate ) $today = [datetime]::UtcNow.Date $periods = [System.Collections.Generic.List[object]]::new() if($Month) { foreach($value in $Month | Select-Object -Unique) { $first = [datetime]::ParseExact("$value-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture) $periods.Add([PSCustomObject]@{ Month = $value; First = $first; Start = $null; End = $null }) } } else { $start = $StartDate.Date $end = $EndDate.Date if($start -gt $end) { throw "-StartDate ($($start.ToString('yyyy-MM-dd'))) es posterior a -EndDate ($($end.ToString('yyyy-MM-dd')))." } $first = [datetime]::new($start.Year, $start.Month, 1) while($first -le $end) { $last = $first.AddMonths(1).AddDays(-1) $periods.Add([PSCustomObject]@{ Month = $first.ToString('yyyy-MM') First = $first # Solo se recorta si el rango no cubre el mes completo. Start = $start -gt $first ? $start : $null End = $end -lt $last ? $end : $null }) $first = $first.AddMonths(1) } } foreach($period in $periods) { if($period.First -gt $today) { throw "El mes $($period.Month) todavia no empieza: no hay costos que consultar." } } $periods } function TestDateInPeriod ([Nullable[datetime]]$Date, $Period) { if(!$Period.Start -and !$Period.End) { return $true } # Sin fecha no se puede ubicar la fila: dentro de un rango recortado se descarta. if(!$Date) { return $false } if($Period.Start -and $Date -lt $Period.Start) { return $false } if($Period.End -and $Date -gt $Period.End) { return $false } return $true } ### Source file: 'RawCsv.ps1' ### function WriteRawCsv { # Mismo formato que Export-Csv de PowerShell 7 (todos los campos entre comillas, UTF-8 sin # BOM), pero escribiendo directo: un mes de una suscripcion grande son decenas de miles de # filas con ~60 columnas, y crear un PSCustomObject por fila lo hace varias veces mas lento. param( [string]$Path, [string[]]$Headers, [object[]]$TagColumns, [System.Collections.Generic.List[object]]$Items ) # foreach y no pipeline: $null | ForEach-Object corre una vez y agregaria una columna nula # cuando no hay tags que exportar. $tagNames = @(foreach($column in $TagColumns) { $column.Tag }) $fields = [string[]]::new($Headers.Count + $tagNames.Count) $writer = [IO.StreamWriter]::new($Path, $false, [Text.UTF8Encoding]::new($false)) try { $columnNames = @($Headers) + @(foreach($column in $TagColumns) { $column.Column }) $writer.WriteLine((ConvertToCsvLine $columnNames)) foreach($item in $Items) { $row = $item.Row $tags = $item.Tags $i = 0 foreach($header in $Headers) { $fields[$i++] = "$($row.$header)" } foreach($tag in $tagNames) { $fields[$i++] = "$($tags[$tag])" } $writer.WriteLine((ConvertToCsvLine $fields)) } } finally { $writer.Dispose() } } function ConvertToCsvLine ([string[]]$Fields) { '"' + [string]::Join('","', [string[]]$Fields.ForEach({ $_.Replace('"', '""') })) + '"' } ### Source file: 'Resources.ps1' ### function NewResourceItem ($Item) { # $Item: fila de Resource Graph (id, name, type, resourceGroup, location, subscriptionId, # tags), recien consultada o leida del cache. $tags = @{} if($Item.tags) { foreach($property in $Item.tags.PSObject.Properties) { $tags[[string]$property.Name] = [string]$property.Value } } [PSCustomObject]@{ PSTypeName = 'KnowIT.AzureCosts.Resource' ResourceId = $Item.id Name = $Item.name Type = $Item.type ResourceGroup = $Item.resourceGroup Location = $Item.location SubscriptionId = $Item.subscriptionId Tags = $tags } } function NewInventoryIndex ([object[]]$Inventory) { # El casing del ResourceId difiere entre el detalle de costos y ARG (ARG devuelve # /resourcegroups/ en minusculas). Indexar en minusculas es lo que hace posible el cruce. $index = @{} foreach($resource in $Inventory) { $index[$resource.ResourceId.ToLowerInvariant()] = $resource } $index } # Sub-recursos que generan cargo con su propio ResourceId pero que Resource Graph no # inventaria. Sin esto aparecen como recursos inexistentes aunque su padre exista. Lista # explicita y no una regla generica de "subir al padre": una base SQL que no esta en ARG # es una base borrada, y sumarla a su servidor la esconderia. $script:ChildToParentTypes = [System.Collections.Generic.HashSet[string]]::new( [string[]]@( 'microsoft.datafactory/factories/pipelines' 'microsoft.datafactory/factories/triggers' ), [StringComparer]::OrdinalIgnoreCase ) function GetParentResourceId ([string]$ResourceIdNorm) { # .../providers/microsoft.datafactory/factories/adf/pipelines/p1 # -> .../providers/microsoft.datafactory/factories/adf $i = $ResourceIdNorm.LastIndexOf('/providers/') if($i -lt 0) { return $null } $parts = $ResourceIdNorm.Substring($i + '/providers/'.Length).Split('/') if($parts.Count -lt 5) { return $null } if(!$script:ChildToParentTypes.Contains("$($parts[0])/$($parts[1])/$($parts[3])")) { return $null } return $ResourceIdNorm.Substring(0, $i) + "/providers/$($parts[0])/$($parts[1])/$($parts[2])" } function GetResourceGroupFromId ([string]$ResourceId) { # /subscriptions/x/resourceGroups/MI-RG/providers/... -> MI-RG $parts = $ResourceId.Split('/', [StringSplitOptions]::RemoveEmptyEntries) for($i = 0; $i -lt $parts.Count - 1; $i++) { if($parts[$i] -eq 'resourcegroups') { return $parts[$i + 1] } } return '' } function TestUntagged ([hashtable]$Tags) { # Un tag presente pero vacio no cuenta como etiquetado. foreach($value in $Tags.Values) { if(![string]::IsNullOrWhiteSpace($value)) { return $false } } return $true } function TestItemFilter { # Filtros comunes a todos los comandos. -like: acepta comodines y no distingue # mayusculas, igual que Azure con nombres de resource group y de tag. param( [string]$ResourceGroup, [hashtable]$Tags, [string[]]$ResourceGroupFilter, [switch]$Untagged, [hashtable]$TagFilter ) if($ResourceGroupFilter) { $match = $false foreach($pattern in $ResourceGroupFilter) { if($ResourceGroup -like $pattern) { $match = $true; break } } if(!$match) { return $false } } if($Untagged -and !(TestUntagged $Tags)) { return $false } if($TagFilter) { foreach($name in $TagFilter.Keys) { if(!$Tags.ContainsKey($name) -or $Tags[$name] -notlike $TagFilter[$name]) { return $false } } } return $true } function AssertFilterCombination ([hashtable]$BoundParameters) { # -Untagged es "sin ningun tag": combinarlo con -Tag no puede devolver nada, y con # -ResourceGroup se decidio mantenerlos excluyentes. if($BoundParameters.ContainsKey('Untagged') -and $BoundParameters.Untagged) { foreach($name in 'ResourceGroup', 'Tag') { if($BoundParameters.ContainsKey($name)) { throw "-Untagged no se puede combinar con -$name." } } } } function AddTagColumns ([object[]]$Items, [string[]]$TagNames, [string[]]$ReservedNames, [string]$TagsProperty = 'Tags') { # Una propiedad por tag, con el nombre del tag tal cual. Si choca con una propiedad # existente (p. ej. un tag 'Location') se le antepone tag_ solo a ese. $reserved = [System.Collections.Generic.HashSet[string]]::new([string[]]$ReservedNames, [StringComparer]::OrdinalIgnoreCase) $columns = @(foreach($tag in $TagNames) { @{ Tag = $tag; Column = $reserved.Contains($tag) ? "tag_$tag" : $tag } }) foreach($item in $Items) { $tags = $item.$TagsProperty foreach($column in $columns) { $item | Add-Member -NotePropertyName $column.Column -NotePropertyValue ($tags[$column.Tag] ?? '') -Force } } } function GetTagNames ([object[]]$TagSets) { # Nombres de tag sin repetir y sin distinguir mayusculas, en orden ordinal para que las # columnas salgan igual en cualquier maquina. $names = @{} foreach($tags in $TagSets) { foreach($key in $tags.Keys) { if(!$names.ContainsKey($key)) { $names[$key] = $key } } } @(SortOrdinal @($names.Values)) } function ConvertTagsToJson ([object[]]$Items) { # Export-Csv escribe una hashtable como 'System.Collections.Hashtable'. En JSON compacto # el CSV conserva los tags; las llaves en orden ordinal para que el texto sea el mismo en # cualquier maquina. Va al final: los filtros y -TagColumn necesitan la hashtable. foreach($item in $Items) { $ordered = [ordered]@{} foreach($key in (SortOrdinal @($item.Tags.Keys))) { $ordered[$key] = $item.Tags[$key] } $item.Tags = ConvertTo-Json -InputObject $ordered -Compress } } ### Source file: 'Sorting.ps1' ### function SortOrdinal { # Orden estable por comparacion ordinal. Sort-Object usa reglas de la cultura y ordena # distinto ('_' antes que los digitos), lo que cambiaria el orden de filas entre # maquinas con distinta configuracion regional. param([object[]]$Items, [scriptblock]$Key = { $_ }) $pairs = [Tuple[string, int][]]::new($Items.Count) for($i = 0; $i -lt $Items.Count; $i++) { $_ = $Items[$i] $pairs[$i] = [Tuple]::Create([string](& $Key), $i) } [Array]::Sort($pairs, [Comparison[Tuple[string, int]]] { param($a, $b) $result = [string]::CompareOrdinal($a.Item1, $b.Item1) $result ? $result : $a.Item2.CompareTo($b.Item2) }) foreach($pair in $pairs) { $Items[$pair.Item2] } } ### Source file: 'Tokens.ps1' ### function GetArmToken ([string]$Tenant) { # Sin -Tenant se usa el del contexto de Az. Con Azure Lighthouse eso es lo correcto: # el token del tenant administrador sirve para las suscripciones delegadas. $params = @{ ResourceUrl = $script:ArmEndpoint AsSecureString = $true Debug = $false } if($Tenant) { $params.Tenant = $Tenant } if(!(Get-AzContext)) { throw 'No hay sesion de Azure. Ejecuta Connect-AzAccount (o Connect-AzAccount -Identity con una managed identity).' } $token = Get-AzAccessToken @params ConvertFrom-SecureString $token.Token -AsPlainText } #endregion #region === Source .psm1 file === #region === Module initialization === $script:ArmEndpoint = 'https://management.azure.com' # En el perfil del usuario, en carpeta .knowit persistiendo reinicios # donde cada modulo KnowIT tiene su propia subcarpeta. $script:CacheRoot = Join-Path $HOME '.knowit' 'KnowIT.AzureCosts' #endregion #endregion |