Saltar al contenido
Microsoft scripts

Scripts de Azure

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.

Antes de correrloCost Management no incluye créditos ni impuestos, y sus datos se actualizan cada cuatro horas: no esperes que coincida con la factura. La API limita las consultas por inquilino; si responde 429, el script espera y reintenta. Si algo no se puede consultar, queda marcado en el reporte como "No se pudo consultar" y el script termina con error para que no lo tomes por completo.

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.csv

Ve 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

  1. Corre el script con una cuenta con Lector de administración de costos en las suscripciones.
  2. Revisa la columna Moneda antes de comparar montos: no se convierte ni se suman monedas distintas.
  3. Ordena por Monto y pregunta a los dueños de los grupos más caros qué cambió.
  4. 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 ', ')" }

Relacionados