En las tres lecciones anteriores has escrito if, case y [[ ]] guiándote por el parecido con otros lenguajes. Funcionaba, pero no sabías por qué. Toca arreglarlo empezando por la idea que en Bash lo gobierna todo y que casi ningún tutorial enuncia con claridad: aquí no se ramifica según un valor booleano, se ramifica según el código de salida de un comando. if no espera una expresión: espera un comando, lo ejecuta y mira si devolvió 0; todo lo demás —[ ], [[ ]], (( ))— son comandos que existen para producir ese 0 o 1. Con esa pieza en su sitio, el resto encaja solo. Al final, revision_salud.sh sabrá decir OK, AVISO o CRÍTICO, y nacerá purgar_releases.sh.

Contenido

  1. La condición es un código de salida
  2. if, elif, else
  3. Los tres tipos de test y cuál usar
  4. Tabla completa de operadores
  5. Expresiones regulares con =~ y BASH_REMATCH
  6. case y sus tres terminadores
  7. Bucles: for, while, until
  8. break, continue y select
  9. Cortocircuitos && y ||, y su trampa
  10. Bucles y rendimiento: cuándo quieres awk
  11. Aplicación: niveles de alerta y purgar_releases.sh

  1. La condición es un código de salida

operador@srv-tramontana:~$ if grep -q 'db_timeout' /var/log/tramontana/errores.log
> then echo "hay timeouts de base de datos"; fi
hay timeouts de base de datos

No hay ningún == true por ninguna parte. if ejecutó grep -q, grep devolvió 0 porque encontró coincidencias, y 0 significa éxito, así que se entró en el then. Si no hubiera encontrado nada habría devuelto 1 y se habría saltado el bloque.

Tres consecuencias que conviene tener presentes desde ya:

  • Cualquier comando puede ser una condición: if ping -c1 -W2 10.0.2.15, if [[ -f fichero ]], if mi_funcion. Todos son lo mismo. Y el [ clásico es un comando de verdad, con su ejecutable en /usr/bin/[; por eso [ $a = $b ] necesita espacios: son argumentos separados.
  • La lógica está invertida respecto a los lenguajes con booleanos: 0 es verdadero. Si escribes una función que comprueba algo, debe devolver 0 cuando la respuesta es «sí».

  1. if, elif, else

uso_disco=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( uso_disco >= 90 )); then
    nivel="CRITICO"
elif (( uso_disco >= 80 )); then
    nivel="AVISO"
else
    nivel="OK"
fi

La sintaxis exige then y fi; el ; antes de then permite ponerlo en la misma línea. Los elif se evalúan en orden y el primero que acierta gana, así que las condiciones van de la más restrictiva a la más laxa: si intercambiaras los dos primeros bloques, un disco al 95 % diría AVISO. Para negar, ! delante: if ! systemctl is-active --quiet tramontana; then .... Y ojo con if [ $? -eq 0 ], casi siempre redundante: pon el comando directamente en el if.

  1. Los tres tipos de test y cuál usar

Forma Qué es Cuándo usarla
[ ... ] o test ... Comando POSIX Solo si el script debe correr en dash
[[ ... ]] Palabra reservada de Bash Siempre, por defecto
(( ... )) Evaluación aritmética Comparaciones y cuentas numéricas

La diferencia entre [ ] y [[ ]] no es cosmética. [[ ]] es sintaxis del propio shell, no un comando, así que Bash no divide en palabras ni expande globs dentro de él:

operador@srv-tramontana:~$ f="informe agosto.txt"
operador@srv-tramontana:~$ [ -f $f ] && echo existe
-bash: [: informe: se esperaban operadores binarios
operador@srv-tramontana:~$ [[ -f $f ]] && echo existe || echo "no existe, pero sin error"
no existe, pero sin error

Con [ ] la variable sin comillas se partió en dos argumentos y el comando ni siquiera pudo evaluarse. Con [[ ]] no hace falta entrecomillar, y además admite &&, ||, patrones y =~, que [ ] no tiene. Para números, (( )) es mucho más legible: compara if (( errores > 20 && conexiones >= 200 )) con [ "$errores" -gt 20 -a "$conexiones" -ge 200 ]. Recuerda de 04-02 que dentro de (( )) las variables van sin $.

  1. Tabla completa de operadores

Grupo Operador Cierto si…
Ficheros -e / -f / -d / -L Existe / es fichero regular / es directorio / es enlace
Ficheros -s / -r / -w / -x No está vacío / permiso de lectura, escritura, ejecución
Ficheros a -nt b / a -ot b a es más nuevo / más viejo que b
Cadenas -z "$s" / -n "$s" Está vacía / no está vacía
Cadenas $s == patrón Coincide con el patrón de globbing (sin comillas a la derecha)
Cadenas == / != / < / > Iguales / distintas / orden lexicográfico según la locale
Cadenas $s =~ regex Coincide con la expresión regular (apartado 5)
Números -eq -ne -lt -le -gt -ge Igual, distinto, menor, menor o igual, mayor, mayor o igual
Números Dentro de (( )) == != < <= > >=, y && || !

La confusión clásica merece un aviso en negrita: -eq es para números y == para cadenas. [[ "01" -eq "1" ]] es cierto (numéricamente iguales) pero [[ "01" == "1" ]] es falso (cadenas distintas). Usar el operador equivocado da resultados correctos durante meses y luego falla con un cero a la izquierda o con una versión como 3.10.

Otro detalle que sorprende: dentro de [[ ]], el lado derecho de == no se entrecomilla si quieres que sea un patrón:

operador@srv-tramontana:~$ v="3.2.1"; [[ $v == 3.2.* ]] && echo "rama 3.2"
rama 3.2
operador@srv-tramontana:~$ [[ $v == "3.2.*" ]] && echo "rama 3.2" || echo "sin coincidencia"
sin coincidencia

Las comillas convierten el patrón en texto literal: es la fuente número uno de «mi comparación no funciona y no sé por qué».

  1. Expresiones regulares con =~ y BASH_REMATCH

=~ compara con una expresión regular extendida (la ERE de 03-02) y, además de decir sí o no, deja las capturas en el array BASH_REMATCH: la posición 0 es la coincidencia completa y las siguientes, los grupos entre paréntesis.

validar_fecha() {
    local fecha="$1"
    if [[ $fecha =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
        printf 'válida: año %s, mes %s, día %s\n' \
            "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
        return 0
    fi
    printf 'formato inválido: %s\n' "$fecha" >&2
    return 1
}
operador@srv-tramontana:~$ validar_fecha "2026-08-18"
válida: año 2026, mes 08, día 18
operador@srv-tramontana:~$ validar_fecha "18/08/2026"; echo "código: $?"
formato inválido: 18/08/2026
código: 1

Con esto validas la columna fecha de reservas.csv antes de fiarte de ella. Dos reglas de oro: la regex no va entre comillas —si la entrecomillas se vuelve texto literal, igual que con ==— y si la guardas en una variable, úsala también sin comillas: [[ $f =~ $PATRON ]].

  1. case y sus tres terminadores

case compara una cadena contra patrones de globbing —no expresiones regulares— y ejecuta la primera rama que case.

clasificar_codigo() {
    case "$1" in
        2*)      echo "éxito" ;;              3*) echo "redirección" ;;
        404)     echo "no encontrado" ;;      4*) echo "error del cliente" ;;
        500|503) echo "error del servidor" ;;  *) echo "desconocido" ;;
    esac
}
operador@srv-tramontana:~$ for c in 200 404 418 503 999; do
>     printf '%s->%s ' "$c" "$(clasificar_codigo "$c")"; done; echo
200->éxito 404->no encontrado 418->error del cliente 503->error del servidor
999->desconocido

El orden importa: 404 va antes que 4* porque gana el primero que case. | separa alternativas y *) es el caso por defecto, que va el último y conviene poner siempre. Los tres terminadores:

Terminador Efecto
;; Termina el case. El que usarás el 99 % de las veces
;& Cae en la rama siguiente sin comprobar su patrón (el fallthrough de C)
;;& Sigue comprobando los patrones restantes y ejecuta los que casen

;;& es raro pero útil para etiquetas acumulativas; ;& es peligroso porque un lector distraído no lo ve. Úsalos solo con un comentario que lo justifique.

  1. Bucles: for, while, until

for sobre listas y globs

for release in 3.1.0 3.2.0 3.2.1; do printf 'release %s\n' "$release"; done
for fichero in /var/log/tramontana/*.log; do
    [[ -e $fichero ]] || continue      # protege del glob sin coincidencias
    printf '%-40s %s líneas\n' "$fichero" "$(wc -l < "$fichero")"
done

La segunda forma —iterar sobre un glob— es la correcta para recorrer ficheros, y trae dos avisos. El primero: si el glob no encuentra nada, Bash deja el patrón literal y el bucle se ejecuta una vez con /var/log/tramontana/*.log como valor; de ahí el [[ -e ... ]] || continue, o bien shopt -s nullglob de 03-02. El segundo, más importante:

Nunca escribas for f in $(ls).

Lo verás en internet constantemente y está mal por tres motivos acumulativos: ls devuelve texto que el shell parte por espacios, así que «informe agosto.txt» se convierte en dos elementos; los caracteres especiales del nombre se expanden como globs; y ls puede colorear o alinear según opciones y alias. El glob no tiene ninguno de esos problemas porque Bash trabaja con nombres de fichero reales, no con texto. Y existe la forma aritmética, for (( i = 0; i < ${#versiones[@]}; i++ )), para cuando el índice importa.

while y until

while repite mientras la condición devuelva 0; until repite hasta que devuelva 0. Son la misma estructura con la condición negada, y until gana cuando la frase natural es «espera hasta que»:

intentos=0
until curl -sf -o /dev/null --max-time 3 "http://10.0.2.15:8080/salud"; do
    (( ++intentos ))
    (( intentos >= 10 )) && { echo "la aplicación no arranca" >&2; exit 69; }
    sleep 2
done
printf 'respondió tras %d intentos\n' "$intentos"

Ese bucle es el que necesita un despliegue para esperar a que el servicio esté listo, y lo reutilizaremos en 04-07. Ya conoces además el while read de 04-03, que es el tercer uso habitual.

  1. break, continue y select

break sale del bucle y continue salta a la siguiente iteración. Ambos aceptan un número que indica cuántos niveles afectan: break 2 sale de dos bucles anidados de golpe.

for dir in /opt/tramontana/releases/*/; do
    for fichero in "$dir"*.conf; do
        [[ -e $fichero ]] || continue
        grep -q 'db_password' "$fichero" || continue
        printf 'credencial en %s\n' "$fichero" >&2
        break 2                     # deja de buscar en todos los releases
    done
done
PS3="Elige un release para desplegar: "     # select: menú numerado gratis
select v in 3.2.1 3.3.0 cancelar; do
    [[ -n $v ]] || { echo "opción no válida" >&2; continue; }
    [[ $v == cancelar ]] && break
    printf 'desplegando %s\n' "$v"; break
done

Si te encuentras con tres niveles de anidamiento, eso no es un problema de bucles: es una función que aún no has escrito, y lo veremos en 04-05. En cuanto a select, es un bucle y sin break no termina nunca; PS3 es el texto del aviso y $REPLY guarda lo que tecleó el usuario.

  1. Cortocircuitos && y ||, y su trampa

Ya los usas desde 02-01: A && B ejecuta B solo si A tuvo éxito; A || B ejecuta B solo si A falló. Como condicional de una línea son insuperables: [[ -d $DIR ]] || mkdir -p "$DIR", o command -v shellcheck >/dev/null || { echo "falta shellcheck" >&2; exit 69; }. Pero mucha gente escribe condicion && accion || otra_cosa creyendo que es un if/else, y no lo es:

operador@srv-tramontana:~$ [[ 5 -gt 3 ]] && grep -q zzz /etc/hostname || echo "¡no!"
¡no!

La clave: || no mira la condición, mira el resultado de la acción anterior. Si la acción falla por cualquier motivo —un mkdir sin permiso, un grep sin coincidencias— la rama del || se ejecuta además de la del &&. La regla: encadena && y || solo cuando la acción no pueda fallar; en cuanto haya un else de verdad, usa if.

  1. Bucles y rendimiento: cuándo quieres awk

Este apartado te ahorrará horas de espera. Contemos las 14 respuestas 500 de acceso.log de dos formas:

operador@srv-tramontana:~$ time (n=0; while read -r l; do
>     grep -q ' 500 ' <<<"$l" && (( ++n )); done < /var/log/tramontana/acceso.log
> echo "$n")
14
real    0m1,873s
operador@srv-tramontana:~$ time awk '$5 == 500 { n++ } END { print n+0 }' \
>     /var/log/tramontana/acceso.log
14
real    0m0,006s

Trescientas veces más rápido, y solo con 412 líneas. La causa no es que Bash interprete despacio: es que el bucle lanza un proceso grep por cada línea, y crear un proceso cuesta unos milisegundos que se multiplican. Con las 10.000 líneas que tendrá acceso.log en un mes, el bucle tardaría casi un minuto y awk seguiría en centésimas. La regla:

  • Si el bucle procesa texto línea a línea, probablemente quieres awk (o grep, sed, sort), que hacen una sola pasada dentro de un único proceso.
  • Si el bucle debe llamar a un comando externo en cada vuelta —curl, systemctl, tar— entonces sí es trabajo de Bash, porque no hay alternativa. Y si necesitas Bash sobre texto, al menos no lances procesos dentro: read con varios nombres, case y expansión de parámetros hacen mucho sin salir del shell.

  1. Aplicación: niveles de alerta y purgar_releases.sh

revision_salud.sh por fin decide. Sustituimos el bloque de impresión por comprobaciones con nivel:

# --- Evaluación ---------------------------------------------------------
nivel_global="OK"
peor() {   # eleva el nivel global si el nuevo es más grave
    case "$1" in
        CRITICO) nivel_global="CRITICO" ;;
        AVISO)   [[ $nivel_global == OK ]] && nivel_global="AVISO" ;;
    esac
}
case "$codigo_http" in
    200) estado_http="OK" ;;
    000) estado_http="CRITICO" ;;    # curl no llegó ni a conectar
    *)   estado_http="AVISO" ;;
esac
if   (( uso_disco >= 90 ));           then estado_disco="CRITICO"
elif (( uso_disco >= UMBRAL_DISCO )); then estado_disco="AVISO"
else                                       estado_disco="OK"; fi
if   (( errores_hoy >= 50 )); then estado_err="CRITICO"
elif (( errores_hoy >= 10 )); then estado_err="AVISO"
else                               estado_err="OK"; fi
peor "$estado_http"; peor "$estado_disco"; peor "$estado_err"

LC_ALL=C printf '%-10s %-8s %s\n' \
    "HTTP"    "$estado_http"  "código $codigo_http" \
    "Disco"   "$estado_disco" "${uso_disco}% usado (umbral ${UMBRAL_DISCO}%)" \
    "Errores" "$estado_err"   "$errores_hoy hoy"
printf '\nEstado global: %s\n' "$nivel_global"
case "$nivel_global" in OK) exit 0 ;; AVISO) exit 1 ;; CRITICO) exit 2 ;; esac

operador@srv-tramontana:~$ ~/scripts/revision_salud.sh; echo "código: $?"
HTTP       OK       código 200
Disco      OK       30% usado (umbral 80%)
Errores    OK       6 hoy

Estado global: OK
código: 0
operador@srv-tramontana:~$ ~/scripts/revision_salud.sh -u 25 | tail -1
Estado global: AVISO

Con esos códigos —0 correcto, 1 aviso, 2 crítico— el script ya sirve para monitorización: Nagios, Zabbix y compañía usan exactamente esa convención. Y el script nuevo:

#!/usr/bin/env bash
#
# purgar_releases.sh - Elimina releases antiguos conservando el activo,
#                      el anterior y los más nuevos.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18
# Uso   : purgar_releases.sh [-n] [-v]   Salida: 0 ok | 2 uso | 66 sin releases
set -euo pipefail
readonly BASE="/opt/tramontana"
readonly DIR_RELEASES="$BASE/releases"
DRY_RUN=0; VERBOSE=0
# log(), error() y ejecutar() son las de 04-03; getopts con ":hnv" fija -n y -v.
activo=$(basename "$(readlink -f "$BASE/app")")   # a donde apunta el enlace
log "release activo: $activo"

# Iteramos sobre el glob (nunca sobre ls) y ordenamos con sort -V, que sabe
# que 3.10.0 va después de 3.9.0, cosa que sort a secas ignora. mapfile
# vuelca cada línea en un elemento del array, sin partir por espacios.
lista=()
for d in "$DIR_RELEASES"/*/; do [[ -d $d ]] && lista+=("$(basename "$d")"); done
(( ${#lista[@]} )) || { error "no hay releases en $DIR_RELEASES"; exit 66; }
mapfile -t versiones < <(printf '%s\n' "${lista[@]}" | sort -V)
indice_activo=-1        # posición del activo, para saber cuál es "el anterior"
for (( i = 0; i < ${#versiones[@]}; i++ )); do
    [[ ${versiones[i]} == "$activo" ]] && indice_activo=$i
done
(( indice_activo >= 0 )) || { error "el activo $activo no está en la lista"; exit 66; }
for (( i = 0; i < ${#versiones[@]}; i++ )); do
    v="${versiones[i]}"
    (( i >= indice_activo - 1 )) && { log "conservo $v"; continue; }
    printf 'purgando %s (%s)\n' "$v" "$(du -sh "$DIR_RELEASES/$v" | cut -f1)"
    ejecutar rm -rf -- "${DIR_RELEASES:?}/$v"
done
exit 0
operador@srv-tramontana:~$ ~/scripts/purgar_releases.sh -n -v
[2026-08-18 11:04:12] release activo: 3.2.1
purgando 3.1.0 (97M)
[simulado] rm -rf -- /opt/tramontana/releases/3.1.0
[2026-08-18 11:04:12] conservo 3.2.0
[2026-08-18 11:04:12] conservo 3.2.1
[2026-08-18 11:04:12] conservo 3.3.0

Justo lo que queríamos: solo 3.1.0 es purgable. Fíjate en dos detalles defensivos: rm -rf -- "${DIR_RELEASES:?}/$v" usa ${VAR:?} de 04-02 para que, si la variable quedara vacía por un error, el comando aborte en vez de convertirse en rm -rf /; y -- evita que un nombre de directorio con guion inicial se lea como opción.

Errores Comunes y Consejos

  • Olvidar los espacios dentro de [ ] o [[ ]]. [[$a == $b]] da orden no encontrada: los corchetes son comandos y palabras reservadas, no sintaxis pegada.
  • Mezclar -eq con cadenas o == con números. [[ "3.10" > "3.9" ]] es falso lexicográficamente: para versiones, sort -V; para números, (( )). Y no entrecomilles el lado derecho de == o =~, o el patrón se vuelve texto literal.
  • for f in $(ls). Se rompe con espacios y con caracteres especiales; usa siempre el glob. Y protege el glob sin coincidencias con [[ -e $f ]] || continue o shopt -s nullglob, o el bucle correrá una vez con el patrón literal.
  • Confiar en cond && accion || otra como if/else. Si accion falla, se ejecutan las dos ramas. Y anidar tres bucles es la señal inequívoca de que falta una función.
  • Consejo: compara números siempre con (( )): es más legible y no confundirás el operador. Pon siempre la rama *) en un case, aunque solo sea para registrar «valor inesperado». Y antes de escribir un bucle que procese texto, pregúntate si awk lo haría de una pasada: casi siempre la respuesta es sí.

Ejercicios

Ejercicio 1. Escribe es_version_valida(), que devuelva 0 si su argumento tiene el formato N.N.N (uno o más dígitos por tramo) y 1 si no, usando =~, e imprima los tres números por separado. Pruébala con 3.2.1, 3.10.0, 3.2 y v3.2.1.

Ejercicio 2. Escribe ~/scripts/resumen_franjas.sh, que sobre acceso.log clasifique cada petición en madrugada (00-05), mañana (06-13), tarde (14-21) o noche (22-23) con case, y muestre peticiones y errores por franja. Hazlo sin lanzar procesos dentro del bucle y compara el tiempo con awk.

Ejercicio 3. Luis ha escrito este fragmento para decidir si hay que purgar. Encuentra los cuatro defectos y reescríbelo.

libre=`df / | tail -1 | awk '{print $5}'`
if [ $libre > 80 ]; then
  echo "purgando" && rm -rf /opt/tramontana/releases/* || echo "error"
fi

Soluciones

Solución 1.

es_version_valida() {
    local v="$1"
    # ^ y $ anclan la coincidencia a TODA la cadena: sin ellos, "v3.2.1x"
    # también casaría. Los paréntesis crean los grupos de BASH_REMATCH.
    [[ $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1
    printf 'mayor=%s menor=%s parche=%s\n' \
        "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
}
for v in 3.2.1 3.10.0 3.2 v3.2.1; do
    if es_version_valida "$v"; then printf '  %-8s válida\n' "$v"
    else printf '  %-8s NO válida\n' "$v"; fi
done

operador@srv-tramontana:~$ bash /tmp/versiones.sh
mayor=3 menor=2 parche=1
  3.2.1    válida
mayor=3 menor=10 parche=0
  3.10.0   válida
  3.2      NO válida        # falta un tramo
  v3.2.1   NO válida        # el ancla ^ rechaza la 'v'

Los puntos van escapados (\.) porque en una regex . significa «cualquier carácter»; sin escapar, 3x2y1 también sería válida. Y fíjate en cómo la función se usa directamente en el if: es el apartado 1 en acción. (Sin return 0 explícito, la función devuelve el código del printf, que es 0.)

Solución 2.

#!/usr/bin/env bash
#
# resumen_franjas.sh - Peticiones y errores por franja horaria en acceso.log.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18
set -euo pipefail
readonly LOG="${1:-/var/log/tramontana/acceso.log}"
declare -A peticiones errores
# read trocea la línea sin lanzar procesos: la hora es el segundo campo y
# el código el quinto. ${hora%%:*} deja solo HH, y el 10# fuerza base
# decimal porque "08" y "09" serían octal inválido (04-02).
while read -r _fecha hora _metodo _ruta codigo _resto; do
    case "$(( 10#${hora%%:*} ))" in
        [0-5])        franja="madrugada" ;;
        [6-9]|1[0-3]) franja="mañana"    ;;
        1[4-9]|2[01]) franja="tarde"     ;;
        *)            franja="noche"     ;;
    esac
    peticiones["$franja"]=$(( ${peticiones["$franja"]:-0} + 1 ))
    [[ $codigo == [45]* ]] && errores["$franja"]=$(( ${errores["$franja"]:-0} + 1 ))
done < "$LOG"

LC_ALL=C printf '%-12s %12s %8s\n' FRANJA PETICIONES ERRORES
for f in madrugada mañana tarde noche; do
    LC_ALL=C printf '%-12s %12d %8d\n' "$f" "${peticiones[$f]:-0}" "${errores[$f]:-0}"
done
operador@srv-tramontana:~$ time ~/scripts/resumen_franjas.sh
FRANJA         PETICIONES  ERRORES
madrugada              88       15
mañana                147        3
tarde                 141        4
noche                  36        1
real  0m0,048s

Los 15 errores de madrugada son los de la franja de las 03:00 que ya conoces, y los 23 totales cuadran. La versión en awk —una línea con substr($2,1,2)+0 y el ternario para la franja— tarda 0,007 s: siete veces menos aun sin procesos dentro del bucle, solo por el coste del intérprete. Pero fíjate en la escala: centésimas contra centésimas. La diferencia catastrófica aparece cuando hay $(...) dentro del bucle, no por el bucle en sí. Y nota el patrón [45]* del case: globbing, no regex.

Solución 3. Los cuatro defectos:

  1. ` ` en vez de $( ). Las backticks no se anidan, tienen reglas de escape distintas y son ilegibles. Además $5 de df incluye el %, así que la comparación numérica recibe 30%.
  2. [ $libre > 80 ]. Dentro de [ ], > no compara: es una redirección, así que ese comando crea un fichero llamado 80 en el directorio actual y el test siempre es cierto. Hay que usar -gt o, mejor, (( )).
  3. rm -rf /opt/tramontana/releases/* borra todo, incluido el release activo al que apunta el enlace app: la aplicación se cae en el acto.
  4. echo ... && rm ... || echo "error". Si el rm falla parcialmente se imprimen ambos mensajes y el script no devuelve ningún código útil. Además no hay --dry-run ni registro de qué se borró.
uso_disco=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( uso_disco > 80 )); then
    printf 'disco al %d%%, purgando releases antiguos\n' "$uso_disco" >&2
    if ~/scripts/purgar_releases.sh -v; then printf 'purga completada\n' >&2
    else printf 'la purga falló (código %d)\n' "$?" >&2; exit 1; fi
fi

La lógica destructiva se delega en purgar_releases.sh, que ya sabe qué conservar, tiene --dry-run y registra lo que hace. Reutilizar en vez de reescribir es también una estructura de control.

Conclusión

Ya no escribes condicionales por imitación: sabes qué ocurre por debajo.

  • Tienes claro que la condición es un código de salida, que 0 es éxito, y que cualquier comando —incluida una función tuya— puede ir en un if.
  • Usas if/elif/else ordenando de la condición más restrictiva a la más laxa, y eliges entre los tres tipos de test: [[ ]] por defecto porque no divide palabras y admite patrones, (( )) para lo numérico, y [ ] solo si necesitas POSIX.
  • Manejas la tabla completa de operadores de fichero, cadena y número; no confundes -eq con == ni entrecomillas el lado derecho de un patrón; validas con =~ y BASH_REMATCH, con la regex sin comillas y anclada con ^ y $; y escribes case con patrones de globbing, el *) obligatorio y los terminadores ;;, ;& y ;;&.
  • Recorres con for sobre globs, nunca sobre $(ls), con la forma aritmética cuando el índice importa, y con while/until para esperas y lecturas; controlas el flujo con break N, continue N y select. Conoces la trampa de cond && accion || otra y la regla de rendimiento: si el bucle procesa texto, probablemente quieres awk, medido en un caso real.
  • Y tus scripts deciden: revision_salud.sh devuelve OK, AVISO o CRÍTICO con códigos aptos para monitorización, y purgar_releases.sh recorre los releases conservando el activo, el anterior y los más nuevos.

Mira ahora los scripts juntos: log() está copiada tres veces, error() también, el envoltorio ejecutar() va por su segunda copia y el bloque de getopts es casi idéntico en todos. Cada vez que mejores uno tendrás que acordarte de mejorar los demás, y algún día no te acordarás. En la siguiente lección, Funciones y Librerías, resolvemos eso: cuándo extraer una función y por qué el nombre es documentación, el ámbito dinámico de Bash y por qué toda variable debe ser local, las tres formas reales de devolver datos cuando return solo admite un número de 0 a 255, y cómo montar lib/comunes.sh con carga robusta, guardas de inclusión y hasta un pequeño test que compruebe que la librería funciona. Al terminarla, esas funciones duplicadas vivirán en un solo sitio.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados