Gasto del mes por grupo de recursos
Muestra lo que lleva gastado cada grupo de recursos en el mes, en la moneda que devuelve Azure, sin convertirla: una suscripción puede facturar en MXN y otra en USD.
Ejemplo en México
Empresa de logística en Monterrey, 250 personas
Antes del cierre de mes, administración quiere saber qué proyecto disparó el gasto; tiene una suscripción en MXN y otra heredada en USD.
.\get-costos-por-grupo.ps1 -RutaCsv .\costos.csvVe el gasto por grupo con su moneda y encuentra que el grupo de rastreo de flotillas concentra la mitad del total en MXN.
Cómo implementarlo
- Corre el script con una cuenta con Lector de administración de costos en las suscripciones.
- Revisa la columna Moneda antes de comparar montos: no se convierte ni se suman monedas distintas.
- Ordena por Monto y pregunta a los dueños de los grupos más caros qué cambió.
- Compara con el mes anterior en el análisis de costos del portal y ajusta presupuestos.
Ver el código completo de get-costos-por-grupo.ps1
<#
.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 ', ')" }