Control de flujo en Bash: if, for, while y case

El control de flujo en Bash tiene una particularidad que lo diferencia de casi cualquier otro lenguaje: la condición de un if no es una expresión booleana, sino un comando. Lo que evalúa Bash es el exit code de ese comando — 0 significa verdadero, cualquier otro valor significa falso. Esto no es una rareza del lenguaje; es coherente con toda la filosofía Unix: cada herramienta devuelve 0 en éxito y distinto de 0 en error. Cuando escribes if grep -q "error" /var/log/syslog, estás usando grep como condición directamente, sin comparar nada.

El operador [[ expresión ]] es un built-in de Bash diseñado para hacer tests de forma segura. No lo confundas con [, que en realidad es un comando externo (pruébalo: type [ vs type [[). La diferencia práctica es que [[ no hace word splitting sobre las variables, lo que significa que [[ -z $variable ]] funciona aunque $variable esté vacía o contenga espacios, mientras que [ -z $variable ] puede romperse. Además, [[ soporta && y || internamente, comparaciones con regex mediante =~, y patrones glob con ==. Úsalo siempre que trabajes en Bash puro; reserva [ solo si necesitas compatibilidad POSIX con sh.

¿Cuándo aplica todo esto? Prácticamente en cualquier script de administración: verificar si existe un archivo antes de procesarlo, iterar sobre una lista de hosts, validar parámetros de entrada. Lo que se rompe si no entiendes el modelo es predecible: variables con espacios que parten en múltiples palabras, condiciones que se evalúan al revés porque confundes el exit code, o bucles for que no expanden globs porque pusiste comillas donde no debías.

#!/usr/bin/env bash
# Script de rotación y auditoría de logs en /var/log/app/

set -euo pipefail

LOG_DIR="/var/log/app"
ARCHIVE_DIR="/var/log/app/archive"
MAX_SIZE_MB=10
NOTIFY_EMAIL="ops@ejemplo.com"

# Crea el directorio de archivo si no existe
if [[ ! -d "$ARCHIVE_DIR" ]]; then
    mkdir -p "$ARCHIVE_DIR"
fi

# Verifica que el directorio de logs sea accesible antes de continuar
if [[ ! -r "$LOG_DIR" ]]; then
    echo "Error: no se puede leer $LOG_DIR" >&2
    exit 1
fi

procesar_log() {
    local archivo="$1"
    local nombre
    nombre=$(basename "$archivo")
    local tamaño_mb
    # Divide entre 1024^2 para pasar de bytes a MB
    tamaño_mb=$(( $(stat -c '%s' "$archivo") / 1048576 ))

    # =~ compara contra expresión regular extendida; la captura queda en BASH_REMATCH
    if [[ "$nombre" =~ ^app-([0-9]{4}-[0-9]{2}-[0-9]{2})\.log$ ]]; then
        local fecha="${BASH_REMATCH[1]}"
        echo "Procesando log con fecha: $fecha"
    else
        echo "Advertencia: nombre de archivo inesperado: $nombre" >&2
    fi

    # Operadores numéricos: -gt compara enteros, no cadenas
    if [[ "$tamaño_mb" -gt "$MAX_SIZE_MB" ]]; then
        gzip -c "$archivo" > "$ARCHIVE_DIR/${nombre}.gz"
        rm "$archivo"
        echo "Archivado: $nombre (${tamaño_mb} MB)"
    elif [[ "$tamaño_mb" -gt 0 ]]; then
        echo "OK: $nombre (${tamaño_mb} MB, sin archivar)"
    else
        echo "Vacío, ignorando: $nombre"
    fi
}

# Glob *.log — sin comillas para que Bash expanda; falla si no hay coincidencias
# con nullglob activo el bucle simplemente no se ejecuta
shopt -s nullglob
archivos=( "$LOG_DIR"/*.log )
shopt -u nullglob

if [[ "${#archivos[@]}" -eq 0 ]]; then
    echo "No hay archivos .log en $LOG_DIR"
    exit 0
fi

for archivo in "${archivos[@]}"; do
    # -f confirma que es un archivo regular, no un symlink roto ni un directorio
    if [[ -f "$archivo" ]]; then
        procesar_log "$archivo"
    fi
done

# Resumen de archivos generados hoy usando rango numérico y aritmética
echo ""
echo "Archivos comprimidos generados en las últimas 24h:"
contador=0
while IFS= read -r linea; do
    [[ -z "$linea" ]] && continue
    (( contador++ ))
    echo "  $linea"
done < <(find "$ARCHIVE_DIR" -name "*.gz" -mtime -1 -printf "%f\n")

# case es más legible que una cadena de if/elif para valores discretos
case "$contador" in
    0)
        echo "Ninguno. Todo estaba bajo el límite."
        ;;
    1)
        echo "Un archivo archivado. Revisión recomendada."
        ;;
    [2-9]|[1-9][0-9])
        echo "Varios archivos archivados ($contador). Considera aumentar MAX_SIZE_MB."
        ;;
    *)
        # Envía alerta solo si sendmail está disponible
        if command -v sendmail &>/dev/null; then
            echo "Alerta: $contador archivos comprimidos hoy" \
                | sendmail "$NOTIFY_EMAIL"
        fi
        echo "Demasiados archivos archivados: $contador"
        ;;
esac

Lo que está pasando en cada parte

La condición como comando aparece desde la primera línea funcional: if [[ ! -d "$ARCHIVE_DIR" ]]. Aquí [[ es el comando; devuelve 0 si el directorio no existe (! invierte el resultado). Fíjate también en if command -v sendmail &>/dev/null más abajo — sin [[ en absoluto, porque command -v ya devuelve 0 si el binario existe.

Las comillas alrededor de variables dentro de [[ son técnicamente opcionales ahí (a diferencia de [), pero mantenerlas es un hábito útil: el día que copies esa condición a un script POSIX, no te romperá nada.

El regex con =~ en [[ "$nombre" =~ ^app-([0-9]{4}-... ]] no va entre comillas en el lado derecho — si lo entrecomillas, Bash lo trata como cadena literal, no como regex. La captura completa queda en BASH_REMATCH[0] y los grupos en BASH_REMATCH[1], [2], etc.

El operador -gt en [[ "$tamaño_mb" -gt "$MAX_SIZE_MB" ]] hace comparación entera. Si usaras > dentro de [[, estarías comparando lexicográficamente — "9" > "10" sería verdadero, lo cual es un bug clásico.

El glob con nullglob merece atención. Sin él, si no hay archivos .log, el glob literal *.log no se expande y el array contiene la cadena "*.log" — el bucle for iteraría una vez sobre ese valor. Activar nullglob produce un array vacío, que luego detectamos con "${#archivos[@]}" -eq 0. Lo desactivamos con shopt -u nullglob para no afectar al resto del script.

El bucle while con sustitución de proceso while ... done < <(find ...) es la forma correcta de leer la salida de un comando línea a línea sin abrir una subshell. Si escribieras find ... | while read, el contador se incrementaría dentro de una subshell y su valor se perdería al salir del pipe — uno de los bugs más frecuentes en scripts Bash de nivel intermedio.

El case con patrón [2-9]|[1-9][0-9] ilustra que los patrones de case son globs, no regex: [2-9] es un rango de caracteres, | separa alternativas. Para números grandes tendrías que añadir más patrones o usar un if aritmético en el bloque *).

74

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio