Saltar al contenido
Microsoft 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.

Antes de correrloDesasignar corta el servicio de la VM, puede cambiar su IP pública dinámica y borra el disco temporal. 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 inquilino 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. Es un cambio reversible: la VM se vuelve a encender cuando la necesites. Usa -WhatIf para ver qué haría sin tocar nada.

.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.
#>
[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 inquilino.
Write-Host "Cuenta: $($contexto.Account.Id). Inquilino: $($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