Saltar al contenido
Microsoft scripts

Scripts de Azure

Discos, IP públicas y NIC sin uso

Encuentra discos sin adjuntar, IP públicas sin asociar y tarjetas de red sin máquina virtual, que suelen quedar después de borrar una VM y algunos siguen cobrando.

Antes de correrloEs un reporte para revisar, no una orden de borrado: un disco sin adjuntar puede ser la única copia de información. Borrar un disco o liberar una IP estática no se puede deshacer.

Ejemplo en México

Despacho contable en Puebla, 40 personas

Migraron servidores el año pasado y la factura no bajó tanto como esperaban.

.\get-recursos-huerfanos.ps1 -RutaCsv .\huerfanos.csv

Encuentra discos Premium de las máquinas viejas sin adjuntar y dos IP estáticas sin usar, y las da de baja tras confirmar con contabilidad.

Cómo implementarlo

  1. Corre el script y abre el CSV agrupado por la columna Clase.
  2. Confirma con el dueño de cada grupo de recursos que el recurso ya no se usa.
  3. Toma una instantánea de los discos que puedan tener información importante.
  4. Borra desde el portal lo que quedó confirmado y anota el ahorro para tu siguiente revisión de costos.
Ver el código completo de get-recursos-huerfanos.ps1
<#
.SYNOPSIS
    ¿Qué discos, IP públicas y NIC quedaron sin uso?

.DESCRIPTION
    Busca discos administrados en estado Unattached, IP públicas sin configuración ni NAT Gateway, y NIC sin máquina virtual ni Private Link. Solo lectura: no borra nada.

.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-recursos-huerfanos.ps1 -RutaCsv .\huerfanos.csv

.NOTES
    Permisos: Lector (Reader) en cada suscripción
    Rol mínimo: Lector (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 }

function ConsultarGrafo($Consulta) {
    $ids = @($activas.subscriptionId)
    # Resource Graph admite hasta 1,000 suscripciones por consulta.
    for ($i = 0; $i -lt $ids.Count; $i += 1000) {
        $lote = @($ids[$i..([Math]::Min($i + 999, $ids.Count - 1))])
        $cuerpo = @{ query = $Consulta; subscriptions = $lote; options = @{ '$top' = 1000 } }
        do {
            $pagina = InvocarAzure POST '/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01' $cuerpo
            $pagina.data
            $cuerpo.options['$skipToken'] = $pagina.'$skipToken'
        } while ($pagina.'$skipToken')
    }
}

$consulta = 'resources | where type in~ ("microsoft.compute/disks", "microsoft.network/publicipaddresses", "microsoft.network/networkinterfaces") | project id, name, type, location, resourceGroup, subscriptionId, managedBy, sku, properties'

$filas = @(foreach ($recurso in ConsultarGrafo $consulta) {
    $p = $recurso.properties
    $hallazgo = switch ("$($recurso.type)".ToLower()) {
        'microsoft.compute/disks' {
            if (-not $recurso.managedBy -and $p.diskState -eq 'Unattached') {
                @{ Clase = 'Disco sin adjuntar'; Detalle = "$($p.diskSizeGB) GB, $($recurso.sku.name)" }
            }
        }
        'microsoft.network/publicipaddresses' {
            if (-not $p.ipConfiguration -and -not $p.natGateway) {
                @{ Clase = 'IP pública sin asociar'; Detalle = "$($p.ipAddress), $($p.publicIPAllocationMethod)" }
            }
        }
        'microsoft.network/networkinterfaces' {
            if (-not $p.virtualMachine -and -not $p.privateEndpoint -and -not $p.privateLinkService -and -not $p.hostedWorkloads) {
                @{ Clase = 'NIC sin uso'; Detalle = '' }
            }
        }
    }
    if ($hallazgo) {
        [pscustomobject]@{
            Clase         = $hallazgo.Clase
            Suscripcion   = $nombres[$recurso.subscriptionId]
            GrupoRecursos = $recurso.resourceGroup
            Recurso       = $recurso.name
            Region        = $recurso.location
            Detalle       = $hallazgo.Detalle
            Id            = $recurso.id
        }
    }
})

Write-Host "Recursos sin uso: $($filas.Count)"
$filas | Group-Object Clase | 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
}

Relacionados