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.
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 -WhatIfConfirma que solo tocaría VMs de desarrollo y programa el runbook de lunes a viernes a las 20:00.
Cómo implementarlo
- Prueba desde tu computadora con -WhatIf para ver qué VMs tocaría.
- 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.
- Crea un runbook de PowerShell con entorno de ejecución 7.x, pega el script y publícalo.
- 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.
- 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." }