Recomendaciones de Azure Advisor
Junta las recomendaciones activas de Azure Advisor de todas tus suscripciones, con su impacto y el ahorro anual cuando Azure lo calcula. Puedes filtrar por categoría.
Ejemplo en México
Tienda en línea en Zapopan, 60 personas
Después del Buen Fin, dirección pide bajar el gasto de Azure sin afectar la tienda.
.\get-recomendaciones-advisor.ps1 -Categoria Cost -RutaCsv .\advisor.csvObtiene VMs para reducir de tamaño y reservas sugeridas con su ahorro anual, y programa los cambios para enero.
Cómo implementarlo
- Corre el script con -Categoria Cost si hoy solo te interesa el ahorro, o sin filtro para verlas todas.
- Ordena por AhorroAnual y revisa la columna Moneda antes de comparar.
- Valida cada recomendación con el dueño del recurso; Advisor no conoce tus picos de temporada.
- Aplica lo aprobado desde el portal y descarta o pospón lo demás para limpiar la lista.
Ver el código completo de get-recomendaciones-advisor.ps1
<#
.SYNOPSIS
¿Qué me recomienda Azure Advisor y cuánto podría ahorrar?
.DESCRIPTION
Lee las recomendaciones de Azure Advisor en Resource Graph y deja las activas. Muestra el ahorro en la moneda que devuelve Azure. Solo lectura: no aplica ninguna recomendación.
.PARAMETER Categoria
Categorías de Advisor que quieres ver. Si se omite, muestra todas.
.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-recomendaciones-advisor.ps1 -Categoria Cost -RutaCsv .\advisor.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.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
[CmdletBinding()]
param(
[ValidateSet('Cost', 'HighAvailability', 'Performance', 'Security', 'OperationalExcellence')][string[]]$Categoria,
[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')
}
}
$impactos = @{ High = 'Alto'; Medium = 'Medio'; Low = 'Bajo' }
$orden = @{ High = 0; Medium = 1; Low = 2 }
$consulta = 'advisorresources | where type =~ "microsoft.advisor/recommendations" | project id, resourceGroup, subscriptionId, properties'
$filas = @(ConsultarGrafo $consulta |
Where-Object {
$_.properties.recommendationStatus -in 'New', 'InProgress', $null -and
(-not $Categoria -or $_.properties.category -in $Categoria)
} |
Sort-Object { if ($orden.ContainsKey("$($_.properties.impact)")) { $orden["$($_.properties.impact)"] } else { 3 } } |
ForEach-Object {
$p = $_.properties
[pscustomobject]@{
Categoria = $p.category
Impacto = if ($impactos["$($p.impact)"]) { $impactos["$($p.impact)"] } else { $p.impact }
Recomendacion = if ($p.shortDescription.solution) { $p.shortDescription.solution } else { $p.label }
Suscripcion = $nombres[$_.subscriptionId]
GrupoRecursos = $_.resourceGroup
Recurso = $p.resourceMetadata.resourceId
AhorroAnual = $p.extendedProperties.annualSavingsAmount
Moneda = $p.extendedProperties.savingsCurrency
}
})
Write-Host "Recomendaciones activas: $($filas.Count)"
$filas | Group-Object Categoria | 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
}