<# .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." }