Saltar al contenido
Microsoft scripts

Scripts de Azure

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.

Antes de correrloEl ahorro es una estimación de Advisor y no todas las recomendaciones lo traen. Las recomendaciones pospuestas o descartadas no aparecen. Las de la categoría Security pueden tardar en reflejar el estado real: confírmalas en Defender for Cloud.

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.csv

Obtiene VMs para reducir de tamaño y reservas sugeridas con su ahorro anual, y programa los cambios para enero.

Cómo implementarlo

  1. Corre el script con -Categoria Cost si hoy solo te interesa el ahorro, o sin filtro para verlas todas.
  2. Ordena por AhorroAnual y revisa la columna Moneda antes de comparar.
  3. Valida cada recomendación con el dueño del recurso; Advisor no conoce tus picos de temporada.
  4. 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
}

Relacionados