Recursos sin las etiquetas obligatorias
Lista los recursos a los que les falta alguna de las etiquetas que indiques, como el centro de costos, para que puedas repartir el gasto por área.
Ejemplo en México
Constructora en Monterrey, 300 personas
Finanzas no puede repartir la factura de Azure entre obras porque muchos recursos no traen centro de costos.
.\get-recursos-sin-etiquetas.ps1 -Etiquetas centro-costos, obra -RutaCsv .\sin-etiquetas.csvRecibe la lista de recursos por grupo y lo que le falta a cada uno, y se la pasa a los residentes de TI antes del cierre del mes.
Cómo implementarlo
- Define con finanzas las etiquetas obligatorias, por ejemplo centro-costos y ambiente.
- Corre el script con esos nombres en -Etiquetas.
- Agrupa el CSV por GrupoRecursos para repartirlo entre los responsables.
- Pide a cada responsable completar las etiquetas y vuelve a correr el reporte para medir el avance.
Ver el código completo de get-recursos-sin-etiquetas.ps1
<#
.SYNOPSIS
¿Qué recursos de Azure no tienen las etiquetas que mi organización exige?
.DESCRIPTION
Revisa las etiquetas de cada recurso y reporta cuáles faltan o están vacías. Solo lectura: no cambia nada.
.PARAMETER Etiquetas
Nombres de las etiquetas que debe tener cada recurso.
.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-recursos-sin-etiquetas.ps1 -Etiquetas centro-costos, ambiente -RutaCsv .\sin-etiquetas.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(
[Parameter(Mandatory)][string[]]$Etiquetas,
[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')
}
}
$recursos = ConsultarGrafo 'resources | project id, name, type, location, resourceGroup, subscriptionId, tags'
$filas = @(foreach ($recurso in $recursos) {
$presentes = @(if ($recurso.tags) {
$recurso.tags.GetEnumerator() | Where-Object { "$($_.Value)".Trim() } | ForEach-Object Key
})
$faltantes = @($Etiquetas | Where-Object { $_ -notin $presentes })
if ($faltantes) {
[pscustomobject]@{
Suscripcion = $nombres[$recurso.subscriptionId]
GrupoRecursos = $recurso.resourceGroup
Recurso = $recurso.name
Tipo = $recurso.type
Region = $recurso.location
EtiquetasFaltantes = $faltantes -join '; '
Id = $recurso.id
}
}
})
Write-Host "Recursos con etiquetas faltantes: $($filas.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
}