Saltar al contenido
MS Scripts

Scripts de Azure

Desasignar VMs por etiqueta fuera de horario

Desasigna las máquinas virtuales con la etiqueta y el valor que indiques, para dejar de pagar cómputo de ambientes de desarrollo en las noches y fines de semana. Se programa como runbook de Azure Automation.

Hace cambiosPide 1 permisoProbado con respuestas simuladas. Sin ejecutar en un tenant real.

Antes de ejecutarloDesasignar corta el servicio de la VM y tiene efectos que no se revierten al encenderla de nuevo: borra el contenido del disco temporal, que no tiene respaldo ni se recupera, y puede cambiar su IP pública dinámica. Si la VM usa disco de sistema operativo efímero, Microsoft advierte que desasignarla pierde el estado del sistema: no le pongas esta etiqueta. Revisa qué tiene esa etiqueta antes de programarlo: una VM de producción mal etiquetada también se apaga. El script no enciende las VMs de nuevo. Requiere PowerShell 7 y Az.Accounts en el entorno de ejecución del runbook. Solo pide la desasignación: no espera a que termine. Antes de actuar muestra la cuenta y el tenant de la sesión: si atiendes a varios clientes, confírmalos.

Ejemplo en México

Software factory en Guadalajara, 90 personas

Los ambientes de desarrollo quedan encendidos toda la noche y los fines de semana; pagan cómputo que nadie usa.

.\set-apagado-vms-por-etiqueta.ps1 -NombreEtiqueta ambiente -ValorEtiqueta desarrollo -WhatIf

Confirma que solo tocaría VMs de desarrollo y programa el runbook de lunes a viernes a las 20:00.

Cómo implementarlo

  1. Prueba desde tu computadora con -WhatIf para ver qué VMs tocaría.
  2. En la cuenta de Automation, activa la identidad administrada asignada por el sistema y dale Colaborador de máquinas virtuales sobre las suscripciones de desarrollo.
  3. Crea un runbook de PowerShell con entorno de ejecución 7.x, pega el script y publícalo.
  4. Agrégale un horario, por ejemplo de lunes a viernes a las 20:00 hora del centro de México, con -NombreEtiqueta, -ValorEtiqueta e -IdentidadAdministrada.
  5. Revisa el historial de trabajos a la mañana siguiente y compara el gasto después de un mes.
Ver el código completo de set-apagado-vms-por-etiqueta.ps1
<#
.SYNOPSIS
    Desasigna las máquinas virtuales que tienen una etiqueta y un valor dados.

.DESCRIPTION
    Pensado para apagar ambientes de desarrollo fuera de horario como runbook de Azure Automation con identidad administrada. Se puede volver a encender la VM, pero lo guardado en el disco temporal se pierde al desasignarla.

.PARAMETER NombreEtiqueta
    Nombre de la etiqueta, por ejemplo ambiente.

.PARAMETER ValorEtiqueta
    Valor exacto de la etiqueta, por ejemplo desarrollo. Distingue mayúsculas y minúsculas.

.PARAMETER IdentidadAdministrada
    Inicia sesión con la identidad administrada asignada por el sistema de la cuenta de Automation y no pide confirmación. Úsalo en el runbook.

.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
    .\set-apagado-vms-por-etiqueta.ps1 -NombreEtiqueta ambiente -ValorEtiqueta desarrollo -WhatIf

.NOTES
    Permisos: Colaborador de máquinas virtuales (Virtual Machine Contributor) en las suscripciones a apagar
    Rol mínimo: Colaborador de máquinas virtuales (Virtual Machine Contributor)
    Licencia: Basta una suscripción de Azure; no requiere plan adicional. Azure Automation tiene su propio cobro por minutos de ejecución.
    Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)][string]$NombreEtiqueta,
    [Parameter(Mandatory)][string]$ValorEtiqueta,
    [switch]$IdentidadAdministrada,
    [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'
}
# Un runbook no puede responder confirmaciones; -WhatIf sigue funcionando.
if ($IdentidadAdministrada) { $ConfirmPreference = 'None' }

function Conectar {
    # Iniciar sesión no es el cambio que protegen -WhatIf y -Confirm.
    $WhatIfPreference = $false
    $ConfirmPreference = 'None'
    if ($IdentidadAdministrada) {
        $null = Disable-AzContextAutosave -Scope Process
        $null = Connect-AzAccount -Identity
    } elseif (-not (Get-AzContext)) {
        $null = Connect-AzAccount
    }
}
Conectar

function InvocarAzure($Metodo, $Ruta, $Cuerpo) {
    $parametros = @{ Method = $Metodo; WhatIf = $false; Confirm = $false }
    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." }
$contexto = Get-AzContext
# Con varios clientes en caché, la sesión puede ser de otro tenant.
Write-Host "Cuenta: $($contexto.Account.Id). Tenant: $($contexto.Tenant.Id)."
$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" | project id, name, resourceGroup, subscriptionId, tags, estado = tostring(properties.extended.instanceView.powerState.code)'

# Se filtra aquí, no en la consulta, para no armar KQL con el texto de los parámetros.
$vms = @(ConsultarGrafo $consulta | Where-Object {
    $_.tags -and ($_.tags.GetEnumerator() | Where-Object { $_.Key -eq $NombreEtiqueta -and "$($_.Value)" -ceq $ValorEtiqueta })
})

$filas = @(foreach ($vm in $vms) {
    $detalle = ''
    if ($vm.estado -in 'PowerState/deallocated', 'PowerState/deallocating') {
        $resultado = 'Ya desasignada'
    } elseif ($PSCmdlet.ShouldProcess("$($vm.name) ($($vm.resourceGroup), suscripción $($nombres[$vm.subscriptionId]))", 'Desasignar máquina virtual')) {
        try {
            $null = InvocarAzure POST "$($vm.id)/deallocate?api-version=2026-04-01"
            $resultado = 'Desasignación solicitada'
        } catch {
            $resultado = 'Falló'
            $detalle = "$_"
            Write-Warning "No se pudo desasignar $($vm.name): $_"
        }
    } else {
        $resultado = 'Omitida (WhatIf o sin confirmar)'
    }
    [pscustomobject]@{
        Suscripcion   = $nombres[$vm.subscriptionId]
        GrupoRecursos = $vm.resourceGroup
        VM            = $vm.name
        EstadoPrevio  = $vm.estado
        Resultado     = $resultado
        Detalle       = $detalle
    }
})

Write-Host "VMs con $NombreEtiqueta=${ValorEtiqueta}: $($filas.Count)"
$filas | Group-Object Resultado | 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 -WhatIf:$false -Confirm:$false }
} elseif ($RutaCsv) {
    # UTF-8 con BOM para que Excel en español muestre bien los acentos.
    $filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM -WhatIf:$false -Confirm:$false
    Write-Host "CSV guardado en $RutaCsv"
} else {
    $filas | Format-Table -AutoSize
}

$fallas = @($filas | Where-Object Resultado -EQ 'Falló')
# El runbook debe terminar en error para que se note en el historial de trabajos.
if ($fallas) { throw "No se pudieron desasignar $($fallas.Count) VM." }

Relacionados