<# .SYNOPSIS ¿Cuánto llevo gastado este mes en cada grupo de recursos? .DESCRIPTION Consulta Cost Management de cada suscripción con el periodo del mes en curso y agrupa por grupo de recursos. Muestra la moneda tal como la devuelve la API y no suma monedas distintas. Solo lectura: el POST de Query es una consulta. .PARAMETER Suscripciones IDs o nombres de suscripción. Si se omite, usa todas a las que tienes acceso. .PARAMETER RutaCsv Ruta del CSV de salida. Si se omite, solo se muestra en pantalla. .EXAMPLE .\get-costos-por-grupo.ps1 -RutaCsv .\costos.csv .NOTES Permisos: Lector de administración de costos (Cost Management Reader) o Lector (Reader) en cada suscripción Rol mínimo: Lector de administración de costos (Cost Management Reader) Licencia: Basta una suscripción de Azure; no requiere plan adicional. Los costos de CSP clásico y patrocinios no están disponibles en Cost Management. Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas. #> [CmdletBinding()] param( [ValidateNotNullOrEmpty()][string[]]$Suscripciones, [string]$RutaCsv ) $ErrorActionPreference = 'Stop' if (-not (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue)) { throw 'Falta el módulo. Instálalo con: Install-Module Az.Accounts -Scope CurrentUser' } if (-not (Get-AzContext)) { $null = Connect-AzAccount } function InvocarAzure($Metodo, $Ruta, $Cuerpo) { $parametros = @{ Method = $Metodo } if ($Ruta -like 'http*') { $parametros.Uri = $Ruta } else { $parametros.Path = $Ruta } if ($Cuerpo) { $parametros.Payload = $Cuerpo | ConvertTo-Json -Depth 10 } # Invoke-AzRestMethod no lanza error con 4xx o 5xx. 429 es una limitación temporal: se reintenta. for ($intento = 1; ; $intento++) { $respuesta = Invoke-AzRestMethod @parametros if ($respuesta.StatusCode -ne 429 -or $intento -ge 4) { break } Start-Sleep -Seconds 30 } if ($respuesta.StatusCode -ge 400) { throw "Azure respondió $($respuesta.StatusCode) en ${Ruta}: $($respuesta.Content)" } if ($respuesta.Content) { $respuesta.Content | ConvertFrom-Json -AsHashtable } } function ListarPaginado($Ruta) { while ($Ruta) { $pagina = InvocarAzure GET $Ruta $pagina.value $Ruta = $pagina.nextLink } } $activas = @(ListarPaginado '/subscriptions?api-version=2022-12-01' | Where-Object { $_.state -notin 'Disabled', 'Deleted' -and (-not $Suscripciones -or $_.subscriptionId -in $Suscripciones -or $_.displayName -in $Suscripciones) }) if (-not $activas) { throw 'No hay suscripciones activas a las que tengas acceso con ese filtro.' } $noEncontradas = @($Suscripciones | Where-Object { $_ -and $_ -notin $activas.subscriptionId -and $_ -notin $activas.displayName }) if ($noEncontradas) { throw "No se encontró o no está activa: $($noEncontradas -join ', '). Revisa -Suscripciones." } $nombres = @{} foreach ($s in $activas) { $nombres[$s.subscriptionId] = $s.displayName } function ConsultarMes($IdSuscripcion, $Medida) { $ruta = "/subscriptions/$IdSuscripcion/providers/Microsoft.CostManagement/query?api-version=2026-06-01" $cuerpo = @{ type = 'ActualCost' timeframe = 'MonthToDate' dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = $Medida; function = 'Sum' } } grouping = @(@{ type = 'Dimension'; name = 'ResourceGroup' }) } } while ($ruta) { $resultado = (InvocarAzure POST $ruta $cuerpo).properties if (-not $resultado) { return } # 204: sin datos $columnas = @($resultado.columns) $iMonto = 0..($columnas.Count - 1) | Where-Object { $columnas[$_].type -eq 'Number' -and $columnas[$_].name -ne 'UsageDate' } | Select-Object -First 1 $iGrupo = [array]::IndexOf(@($columnas.name), 'ResourceGroup') $iMoneda = [array]::IndexOf(@($columnas.name), 'Currency') foreach ($fila in $resultado.rows) { [pscustomobject]@{ Grupo = $fila[$iGrupo]; Monto = $fila[$iMonto]; Moneda = $fila[$iMoneda] } } $ruta = $resultado.nextLink } } $sinConsultar = @() $filas = @(foreach ($s in $activas) { $renglones = $null # shortcut: PreTaxCost es lo que documenta Learn; Cost es el respaldo si un tipo de contrato responde 400. foreach ($medida in 'PreTaxCost', 'Cost') { try { $renglones = @(ConsultarMes $s.subscriptionId $medida) break } catch { if ($medida -eq 'Cost' -or "$_" -notmatch 'respondió 400') { Write-Warning "No se pudo consultar $($s.displayName): $_" $sinConsultar += $s.displayName break } } } foreach ($renglon in $renglones) { [pscustomobject]@{ Suscripcion = $s.displayName GrupoRecursos = if ($renglon.Grupo) { $renglon.Grupo } else { '(sin grupo de recursos)' } Monto = [Math]::Round([double]$renglon.Monto, 2) Moneda = $renglon.Moneda } } }) $filas = @($filas | Sort-Object Moneda, @{ Expression = 'Monto'; Descending = $true }) Write-Host "Grupos de recursos con gasto: $($filas.Count)" $filas | Group-Object Moneda | ForEach-Object { Write-Host " Total $($_.Name): $([Math]::Round(($_.Group | Measure-Object Monto -Sum).Sum, 2))" } if ($sinConsultar) { Write-Host " No se pudo consultar: $(@($sinConsultar).Count)" $filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Suscripcion = $_; GrupoRecursos = 'No se pudo consultar'; Monto = ''; Moneda = '' } }) } if ($filas.Count -eq 0) { Write-Host 'Sin resultados.' # Sin esto, el CSV de una corrida anterior parecería el resultado de esta. if ($RutaCsv) { Set-Content -Path $RutaCsv -Value '' -Encoding utf8BOM } } elseif ($RutaCsv) { # UTF-8 con BOM para que Excel en español muestre bien los acentos. $filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM Write-Host "CSV guardado en $RutaCsv" } else { $filas | Format-Table -AutoSize } # El reporte ya quedó escrito; el error avisa que está incompleto. if ($sinConsultar) { throw "Reporte incompleto, no se pudo consultar: $(@($sinConsultar) -join ', ')" }