Saltar al contenido
Microsoft scripts

Scripts de Azure

Suscripciones sin presupuesto ni alerta de gasto

Te dice qué suscripciones no tienen un presupuesto de Cost Management o lo tienen sin ninguna notificación activa, para que un gasto inesperado no te tome por sorpresa.

Antes de correrloSolo mira presupuestos a nivel de suscripción. Los definidos en un grupo de recursos o en la cuenta de facturación no cuentan, así que una suscripción cubierta por ellos puede aparecer como "Sin presupuesto". Tampoco cuenta alertas de anomalías. 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

Universidad privada en León, 3,000 alumnos

Un laboratorio dejó encendidas máquinas costosas todo un semestre y nadie se enteró hasta ver la factura.

.\get-suscripciones-sin-presupuesto.ps1 -RutaCsv .\presupuestos.csv

Detecta que las suscripciones de los laboratorios no tienen presupuesto y les pone alertas antes de iniciar el siguiente ciclo escolar.

Cómo implementarlo

  1. Corre el script con una cuenta con Lector de administración de costos.
  2. Filtra la columna Estado por "Sin presupuesto" y "Presupuesto sin alerta".
  3. Acuerda con finanzas un monto mensual por suscripción.
  4. Crea en Cost Management los presupuestos con avisos al 80 % y al 100 %, y vuelve a correr el reporte para confirmarlo.
Ver el código completo de get-suscripciones-sin-presupuesto.ps1
<#
.SYNOPSIS
    ¿Qué suscripciones no tienen presupuesto o no avisan cuando se pasan?

.DESCRIPTION
    Revisa los presupuestos a nivel de suscripción y sus notificaciones activas. Una suscripción que no se pudo consultar queda marcada aparte, no como "sin presupuesto". Solo lectura: no crea presupuestos.

.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-suscripciones-sin-presupuesto.ps1 -RutaCsv .\presupuestos.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.
    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 }

$filas = @(foreach ($s in $activas) {
    try {
        $presupuestos = @(ListarPaginado "/subscriptions/$($s.subscriptionId)/providers/Microsoft.CostManagement/budgets?api-version=2026-06-01")
        $conAlerta = @($presupuestos | Where-Object {
            $_.properties.notifications -and ($_.properties.notifications.GetEnumerator() | Where-Object { $_.Value.enabled })
        })
        $estado = if (-not $presupuestos) { 'Sin presupuesto' }
                  elseif (-not $conAlerta) { 'Presupuesto sin alerta' }
                  else { 'Con presupuesto y alerta' }
    } catch {
        Write-Warning "No se pudo consultar $($s.displayName): $_"
        $presupuestos = @(); $conAlerta = @()
        $estado = 'No se pudo consultar'
    }
    [pscustomobject]@{
        Suscripcion            = $s.displayName
        SuscripcionId          = $s.subscriptionId
        Estado                 = $estado
        Presupuestos           = $presupuestos.Count
        PresupuestosConAlerta  = $conAlerta.Count
    }
})

Write-Host "Suscripciones revisadas: $($filas.Count)"
$filas | Group-Object Estado | ForEach-Object { Write-Host "  $($_.Name): $($_.Count)" }

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.
$sinConsultar = @($filas | Where-Object Estado -EQ 'No se pudo consultar')
if ($sinConsultar) { throw "Reporte incompleto, no se pudo consultar: $($sinConsultar.Suscripcion -join ', ')" }

Relacionados