Saltar al contenido
Microsoft scripts

Scripts de Azure

VMs sin protección de Azure Backup

Encuentra máquinas virtuales que no tienen Azure Backup configurado o cuyo respaldo está detenido, en pausa o con error.

Antes de correrloSolo reconoce Azure Backup de VM completa en almacenes de Recovery Services a los que tengas acceso de lectura: si el almacén está en una suscripción que no consultas, la VM se ve sin respaldo. No detecta respaldos de terceros ni ASR.

Ejemplo en México

Hospital privado en Saltillo, 500 personas

Un área creó VMs para el expediente clínico por su cuenta y sistemas no sabe si se están respaldando.

.\get-vms-sin-respaldo.ps1 -RutaCsv .\vms-sin-respaldo.csv

Encuentra las VMs nuevas sin respaldo y las incluye en la política diaria el mismo día.

Cómo implementarlo

  1. Corre el script y separa las VMs por Situacion.
  2. Pregunta a cada dueño si la VM necesita respaldo; una VM desechable de pruebas quizá no.
  3. Protege las que faltan desde un almacén de Recovery Services con la política adecuada.
  4. Atiende los respaldos detenidos o con error y vuelve a correr el reporte.
Ver el código completo de get-vms-sin-respaldo.ps1
<#
.SYNOPSIS
    ¿Qué máquinas virtuales no están protegidas con Azure Backup?

.DESCRIPTION
    Cruza las VMs con los elementos protegidos de los almacenes de Recovery Services en Resource Graph y deja las que no están protegidas. Solo lectura: no configura respaldos.

.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-vms-sin-respaldo.ps1 -RutaCsv .\vms-sin-respaldo.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. El reporte no cuesta; proteger una VM con Azure Backup sí tiene cobro aparte.
    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 =~ "microsoft.compute/virtualmachines"
| extend idVm = tolower(id)
| join kind=leftouter (
    recoveryservicesresources
    | where type =~ "microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems"
    | where properties.backupManagementType == "AzureIaasVM"
    | project idVm = tolower(tostring(properties.sourceResourceId)), estadoRespaldo = tostring(properties.protectionState), ultimoRespaldo = tostring(properties.lastBackupTime)
) on idVm
| project id, name, resourceGroup, subscriptionId, location, estadoRespaldo, ultimoRespaldo
'@

$filas = @(foreach ($vm in ConsultarGrafo $consulta) {
    if ($vm.estadoRespaldo -notin 'Protected', 'IRPending') {
        [pscustomobject]@{
            Suscripcion    = $nombres[$vm.subscriptionId]
            GrupoRecursos  = $vm.resourceGroup
            VM             = $vm.name
            Region         = $vm.location
            Situacion      = if ($vm.estadoRespaldo) { "Respaldo no activo: $($vm.estadoRespaldo)" } else { 'Sin respaldo configurado' }
            UltimoRespaldo = $vm.ultimoRespaldo
            Id             = $vm.id
        }
    }
})

Write-Host "VMs sin protección activa: $($filas.Count)"
$filas | Group-Object Situacion | 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
}