Módulo 5: Máquinas remotas, redes y scripting
7. Lógica y seguridad en scripts: condicionales, bucles y set -euo pipefail
Descripción
Al final de esta lección vas a poder escribir un script que decide en vez de solo ejecutar en línea recta: que valida sus argumentos antes de hacer nada, que revisa si un directorio o un archivo existen antes de asumirlo, que repite una acción sobre una lista de elementos o sobre cada línea de un archivo, que termina con un código de salida propio y documentado según lo que pasó, y que se blinda contra fallos silenciosos con set -euo pipefail y limpia sus archivos temporales con trap sin importar cómo termine.
Esto es la diferencia entre un script que "funciona en tu máquina, en el caso feliz" y uno que puedes confiarle a un compañero de equipo o dejar corriendo sin supervisión en un cron. Un script sin validación de argumentos que recibe un dato inesperado no falla con un mensaje claro — falla tres pasos más adelante, con un error críptico de un comando que nunca debió haber corrido, o peor, corre hasta el final y deja algo a medio hacer sin que nadie se entere. Un script sin manejo de errores es el que borra el archivo equivocado a las tres de la mañana porque una variable venía vacía y nadie lo notó hasta el día siguiente.
Conexión con el módulo: en la lección anterior convertiste una secuencia de comandos en un archivo .sh que corre de arriba hacia abajo, sin tomar ninguna decisión. Hoy le agregas exactamente eso: lógica y seguridad. Todo lo que viste — parámetros posicionales, comillas, $( ) — sigue ahí, pero ahora tu script puede preguntar "¿existe esto?", "¿qué opción me pidieron?", "¿tengo que repetir esto sobre una lista?", y responder con un código de salida que otro script (o tú mismo, un mes después) puede leer sin adivinar.
Decisiones en la terminal: if, elif, else y las pruebas con [[ ]]
Piensa en un script sin condicionales como una receta que asume que la alacena siempre tiene todos los ingredientes: si falta uno, la receta sigue cocinando a ciegas y el resultado es un plato arruinado, no una parada a tiempo para avisar "falta la harina, no puedo seguir". Un condicional es justamente esa pausa: antes de seguir, el script revisa una condición y decide qué camino tomar.
La sintaxis en bash es:
if [[ condición ]]; then
comandos
elif [[ otra_condición ]]; then
comandos
else
comandos
fi
elif y else son opcionales — puedes tener un if solo, con su fi de cierre y nada más. Lo que sí necesitas entender es qué va adentro de [[ ]]. Bash tiene dos formas de escribir una prueba: [ ] (el comando test clásico, heredado del shell original de Unix) y [[ ]] (una palabra clave propia de bash, más nueva). En scripts de bash modernos, usa [[ ]]: no hace expansión de patrones ni separación de palabras sobre las variables adentro de la misma forma agresiva que [ ], y te deja usar && y || directamente sin la sintaxis más torpe de -a/-o. Aun así, sigue siendo buena costumbre poner las variables entre comillas dentro de [[ ]] — vas a ver exactamente por qué en los errores comunes de esta lección.
Ejemplo trabajado
Vas a construir un script que revisa si un directorio de release está listo para desplegar: que el directorio exista y que tenga adentro un archivo de configuración.
#!/usr/bin/env bash
#
# deploy-guard.sh — valida que un directorio de release tenga lo necesario.
# Uso: ./deploy-guard.sh <directorio>
target_dir="$1"
if [[ ! -d "$target_dir" ]]; then
echo "Error: no existe el directorio $target_dir"
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
echo "Error: falta $target_dir/deploy.conf"
else
echo "Listo: $target_dir tiene lo necesario para desplegar."
fi
Corre el script contra un directorio que no existe:
chmod +x deploy-guard.sh
./deploy-guard.sh release-fantasma
Qué esperar:
Error: no existe el directorio release-fantasma
Ahora crea el directorio pero sin el archivo de configuración, y vuelve a correrlo:
mkdir -p release
./deploy-guard.sh release
Qué esperar:
Error: falta release/deploy.conf
El if fue falso (-d sí encontró el directorio), así que bash pasó a evaluar el elif — y ahí sí encontró el problema real. Completa el archivo y corre una tercera vez:
touch release/deploy.conf
./deploy-guard.sh release
Qué esperar:
Listo: release tiene lo necesario para desplegar.
Ninguna de las dos condiciones falló, así que bash llegó al else. Este script todavía no le sirve a nadie más que a ti frente a la pantalla — no distingue "faltó el directorio" de "faltó el archivo" con un código que otro programa pueda leer, y no valida si le diste algún argumento siquiera. Eso lo arreglas más adelante en esta misma lección.
Pruebas de archivos, números y cadenas: eligiendo la comparación correcta
Cada tipo de dato que quieras comparar tiene su propia prueba, y mezclarlas es una fuente constante de bugs sutiles. Esta tabla cubre las que vas a usar todo el tiempo:
| Prueba | Verdadero cuando... |
|---|---|
-f archivo | archivo existe y es un archivo regular (no un directorio, no un socket) |
-d directorio | directorio existe y es un directorio |
-z cadena | cadena tiene longitud cero (está vacía) |
-n cadena | cadena tiene longitud mayor que cero (no está vacía) |
-x archivo | archivo existe y tiene permiso de ejecución para ti |
Para números, la comparación no usa < ni > — esos operadores, adentro de [[ ]], comparan cadenas de texto ordenadas alfabéticamente, no cantidades. [[ 10 > 9 ]] da falso, porque como texto la cadena "10" va antes que "9". Para comparar cantidades usas estos operadores heredados del test original:
| Operador | Significado |
|---|---|
-eq | igual a |
-ne | distinto de |
-lt | menor que |
-le | menor o igual que |
-gt | mayor que |
-ge | mayor o igual que |
if [[ "$retry_count" -ge 3 ]]; then
echo "Ya se intentó 3 veces o más."
fi
Para cadenas de texto, == y != sí comparan el valor completo, letra por letra:
if [[ "$environment" == "production" ]]; then
echo "Estás apuntando a producción."
fi
Menús de opciones con case
Un if/elif encadenado se vuelve difícil de leer apenas tienes más de tres o cuatro opciones posibles para una misma variable — es como un conmutador telefónico donde cada llamada se compara una por una contra una lista larga de extensiones, en vez de mirar el número marcado y saltar directo a la línea correcta. case hace justamente eso: compara un valor contra una lista de patrones y salta al primero que coincide, sin evaluar los demás.
case "$valor" in
patrón1)
comandos
;;
patrón2|patrón3)
comandos
;;
*)
comandos
;;
esac
Cada rama termina con ;; (no fi, no elif) y todo el bloque cierra con esac (case al revés, la misma convención que usa bash para cerrar if→fi y do→done). El patrón * funciona como comodín — atrapa cualquier valor que no haya coincidido con nada anterior, el equivalente al else final. El | entre patrones te deja tratar varias entradas de la misma forma sin repetir el bloque de comandos.
Ejemplo trabajado
Extiende deploy-guard.sh para que reciba una segunda acción además del directorio:
#!/usr/bin/env bash
#
# deploy-guard.sh — valida o limpia un directorio de release.
# Uso: ./deploy-guard.sh <directorio> {check|clean}
target_dir="$1"
action="$2"
case "$action" in
check)
if [[ ! -d "$target_dir" ]]; then
echo "Error: no existe el directorio $target_dir"
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
echo "Error: falta $target_dir/deploy.conf"
else
echo "Listo: $target_dir tiene lo necesario para desplegar."
fi
;;
clean)
echo "Modo limpieza — lo completas en la próxima sección de esta lección."
;;
*)
echo "Acción desconocida: $action"
echo "Uso: $0 <directorio> {check|clean}"
;;
esac
chmod +x deploy-guard.sh
./deploy-guard.sh release check
./deploy-guard.sh release borrar-todo
Qué esperar:
Listo: release tiene lo necesario para desplegar.
Acción desconocida: borrar-todo
Uso: ./deploy-guard.sh <directorio> {check|clean}
La primera llamada coincidió con la rama check y corrió exactamente la misma lógica de if/elif/else de la sección anterior. La segunda no coincidió con check ni con clean, así que cayó en el comodín * — sin que tuvieras que escribir una comparación explícita para cada entrada inválida posible.
Repetir sobre listas y archivos: for y while read
Cuando necesitas hacer lo mismo sobre varios elementos, escribir el mismo bloque de código una vez por cada uno es exactamente el tipo de repetición manual que un script existe para evitar. Bash tiene dos herramientas distintas para esto, y elegir la equivocada es una fuente común de bugs: for cuando ya sabes cuáles son todos los elementos (una lista fija, o los archivos que coinciden con un patrón), y while read cuando estás leyendo línea por línea el contenido de un archivo que puede tener cualquier cantidad de líneas.
for sobre una lista explícita:
for host in web-01 web-02 db-01; do
echo "Revisando $host..."
done
for sobre archivos que coinciden con un patrón (bash expande el patrón antes de que el bucle empiece a correr):
for log_file in ./release/*.log; do
echo "Encontrado: $log_file"
done
while read para procesar un archivo línea por línea — útil cuando el archivo puede tener diez líneas o diez mil, y no quieres ni necesitas escribirlas todas a mano en un for:
while IFS= read -r host; do
echo "Procesando $host"
done < hosts.txt
Tres detalles de esa línea que no son decorativos. -r en read evita que interprete la barra invertida como carácter de escape dentro de cada línea — la misma razón por la que ya la usaste con read -p en la lección anterior. IFS= (vacío, antes de read) evita que bash recorte espacios al principio o al final de cada línea, algo que read hace por defecto y que puede arruinarte una línea que empieza con un espacio intencional. Y < hosts.txt al final — no una tubería (|) — alimenta el bucle directamente desde el archivo. Esto importa más de lo que parece: vas a ver exactamente por qué en los errores comunes de esta lección.
Ejemplo trabajado
Crea un archivo con una lista de hosts, uno por línea:
printf '%s\n' web-01 web-02 db-01 > hosts.txt
Ahora recorre el archivo línea por línea, contando cuántos hosts procesaste:
count=0
while IFS= read -r host; do
echo "Verificando $host"
count=$((count + 1))
done < hosts.txt
echo "Total de hosts: $count"
Qué esperar:
Verificando web-01
Verificando web-02
Verificando db-01
Total de hosts: 3
El contador count sí conserva su valor después de que el bucle termina — porque el while corrió en el mismo shell que el resto del script, gracias a < hosts.txt. Este mismo patrón (una lista de hosts, un bucle que la recorre uno por uno) es exactamente lo que vas a usar en el proyecto final del módulo para conectarte por SSH a cada máquina de una lista, así que vale la pena que quede firme desde ahora.
Terminar con intención: exit y códigos de salida documentados
En la lección de diagnóstico de red de este módulo ya practicaste una idea clave: un código de estado no es un veredicto binario de "funciona o no funciona" — es información que hay que leer (un 301 no es un error, es una respuesta HTTP válida que dice "el recurso se movió"). Un código de salida de proceso es la misma idea aplicada a scripts. Cada comando que corre en tu terminal termina con un número entre 0 y 255, guardado en la variable especial $?, que solo existe hasta que corres el siguiente comando. Por convención — no por obligación del sistema operativo, sino por acuerdo entre programas — 0 significa éxito y cualquier valor distinto de cero significa algún tipo de fallo. Tres códigos ya vienen reservados por bash mismo: 126 (encontró el archivo pero no pudo ejecutarlo, típicamente por permisos), 127 (el comando no existe) y 128 + N (el proceso murió por la señal número N; por ejemplo 130 es 128 + 2, y la señal 2 es la que envía Ctrl-C).
Tu script también puede elegir sus propios códigos con exit N, y la parte que de verdad importa es documentarlos, no solo elegirlos. Sin documentación, un código de salida 4 no le dice nada a nadie; con un comentario al inicio del script que dice qué significa cada número, se vuelve información que otro script — o un compañero de equipo, o tú en seis meses — puede leer y actuar en consecuencia sin tener que abrir el código fuente.
#!/usr/bin/env bash
#
# deploy-guard.sh
# Códigos de salida:
# 0 — todo en orden
# 2 — uso incorrecto (faltan argumentos)
# 3 — el directorio no existe
# 4 — falta el archivo de configuración
target_dir="$1"
if [[ ! -d "$target_dir" ]]; then
echo "Error: no existe el directorio $target_dir" >&2
exit 3
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
echo "Error: falta $target_dir/deploy.conf" >&2
exit 4
fi
echo "Listo: $target_dir tiene lo necesario para desplegar."
exit 0
Dos detalles nuevos ahí. exit N termina el script inmediatamente con ese código — ninguna línea después de un exit alcanzado llega a correr. Y los mensajes de error ahora van a >&2 (la salida de error estándar) en vez de a la salida normal — así, quien use tu script puede separar los mensajes de diagnóstico de los resultados reales, por ejemplo redirigiendo 2>/dev/null si solo le interesa la salida limpia.
Blindar el script: set -euo pipefail bandera por bandera
Un script sin set -euo pipefail es como una casa sin disyuntor eléctrico: si algo hace un corto circuito, la electricidad sigue fluyendo hasta que el problema se vuelve un incendio, en vez de cortarse en el instante exacto en que algo anormal ocurre. Estas tres banderas, activadas juntas al inicio del script, convierten fallos que de otra forma seguirían corriendo en silencio en una parada inmediata y ruidosa.
set -euo pipefail
-e (errexit). Si cualquier comando termina con código distinto de cero, el script se detiene ahí mismo, en vez de seguir a la siguiente línea como si nada hubiera pasado. Tiene excepciones importantes que vale la pena conocer: no aplica a un comando que estás evaluando dentro de la condición de un if, while o until (ahí el fallo es información, no un accidente), no aplica al comando que está antes de un && o || en una lista (solo importa el último de la cadena), y no aplica a un comando negado con !.
-u (nounset). Referenciar una variable que nunca se definió deja de ser una cadena vacía silenciosa y se convierte en un error que detiene el script. Esto atrapa el típico error de tipeo ($hots_file en vez de $hosts_file) en el momento en que ocurre, no varias líneas después cuando el valor vacío ya causó otro problema.
-o pipefail. Sin esta bandera, una tubería como comando_a | comando_b solo reporta el código de salida de comando_b — si comando_a falla pero comando_b igual corre y termina en 0, toda la tubería se reporta como exitosa. Con pipefail activado, el código de salida de la tubería completa es el del último comando que falló dentro de ella, no necesariamente el último en posición.
Ejemplo trabajado
Ahora el caso conocido en que -e no te protege, incluso con las tres banderas activas. Este bug es real y está bien documentado: cuando declaras y asignas una variable local en la misma línea, el código de salida que importa es el de local, no el del comando que le diste adentro.
#!/usr/bin/env bash
set -euo pipefail
check_disk() {
local result="$(df -h /nonexistent-mount-point 2>/dev/null)"
echo "Resultado: $result"
}
check_disk
echo "Si ves esta línea, set -e no te protegió del fallo de df."
chmod +x check-disk.sh
./check-disk.sh
Qué esperar:
Resultado:
Si ves esta línea, set -e no te protegió del fallo de df.
df contra una ruta que no existe falla con código distinto de cero — pero local result="$(df ...)" es, para bash, una sola sentencia cuyo código de salida final es el de local, y local en sí mismo siempre tiene éxito (0) mientras la declaración de la variable sea válida. El fallo de df queda enmascarado, y el script sigue corriendo como si nada hubiera pasado. La corrección es separar la declaración de la asignación en dos líneas:
check_disk() {
local result
result="$(df -h /nonexistent-mount-point 2>/dev/null)"
echo "Resultado: $result"
}
Con la asignación en su propia línea, su código de salida ya no queda tapado por el de local, y set -e sí detiene el script exactamente ahí — nunca llegas a ver el echo de después. Este es exactamente el tipo de fallo silencioso que set -euo pipefail promete evitar, pero que un detalle de sintaxis puede volver a colar si no sabes que existe.
trap: limpieza automática al salir
Piensa en trap como una nota pegada en la puerta que dice "apaga las luces al salir" — no importa por cuál puerta salgas: la principal, la de emergencia, o si alguien te sacó a los gritos. trap registra un comando que bash corre justo antes de que el script termine, sin importar la razón: llegó al final, alguien llamó a exit, o set -e lo detuvo por un fallo. Esto es exactamente lo que necesitas para archivos temporales: si los borras solo en la última línea del script, cualquier salida anticipada — por error o por diseño — los deja tirados.
tmp_report="$(mktemp)"
trap 'rm -f "$tmp_report"' EXIT
mktemp crea un archivo temporal vacío con un nombre único (típicamente adentro de /tmp) y te devuelve su ruta — así nunca corres el riesgo de pisar un archivo que ya existía por elegir tú mismo el nombre. trap 'comando' EXIT registra comando para que corra al salir; usa comillas simples para que $tmp_report se expanda en el momento en que el trap efectivamente dispara, no en el momento en que lo registras (en este caso da igual porque la variable ya tiene su valor final, pero es el hábito correcto si la variable pudiera cambiar más adelante en el script).
Ejemplo trabajado
#!/usr/bin/env bash
set -euo pipefail
tmp_report="$(mktemp)"
trap 'rm -f "$tmp_report"' EXIT
echo "Escribiendo reporte temporal en $tmp_report"
printf 'host,status\nweb-01,ok\n' > "$tmp_report"
cat "$tmp_report"
# Fallo forzado a propósito, para comprobar que la limpieza igual corre.
false
echo "Esta línea nunca corre."
chmod +x report-demo.sh
./report-demo.sh; echo "Código de salida: $?"
Qué esperar (la ruta exacta del archivo temporal va a variar en tu máquina):
Escribiendo reporte temporal en /tmp/tmp.a1B2c3D4e5
host,status
web-01,ok
Código de salida: 1
El script nunca llegó al último echo — false siempre falla, y con set -e activo eso detuvo la ejecución ahí mismo, con código de salida 1. Pero el archivo temporal ya no existe: confírmalo con ls "$tmp_report" (va a fallar con "No such file or directory", porque bash ya perdió el valor de $tmp_report al terminar el script — pero si guardaste la ruta antes de correrlo, vas a ver que efectivamente desapareció). El trap corrió exactamente en el instante en que el script murió por el fallo de false, sin que tuvieras que acordarte de limpiar manualmente en cada punto de salida posible.
Validar argumentos y ofrecer --help
Con set -u activo aparece un detalle que sorprende la primera vez: si tu script no recibió ningún argumento, escribir $1 a secas dispara el error de "variable no definida" que tú mismo activaste, antes de que puedas siquiera mostrarle el uso correcto al usuario. Por eso, la validación de argumentos casi siempre empieza con ${1:-} (se expande a una cadena vacía si $1 no existe, en vez de fallar) en lugar de $1 solo.
Junta todo lo de esta lección en una versión completa de deploy-guard.sh:
#!/usr/bin/env bash
#
# deploy-guard.sh — valida o limpia un directorio de release.
# Uso: ./deploy-guard.sh <directorio> {check|clean|help}
# Códigos de salida:
# 0 — todo en orden (o --help solicitado explícitamente)
# 2 — uso incorrecto (falta el argumento, o acción no reconocida)
# 3 — el directorio no existe
# 4 — falta el archivo de configuración
set -euo pipefail
readonly EXIT_USAGE=2
readonly EXIT_NO_TARGET=3
readonly EXIT_NO_CONFIG=4
usage() {
printf '%s\n' \
"Uso: $0 <directorio> {check|clean|help}" \
" check valida que el directorio y deploy.conf existan (opción por defecto)" \
" clean borra los .log dentro del directorio" \
" help muestra este mensaje"
}
if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -lt 1 ]]; then
echo "Error: falta el directorio del release." >&2
usage
exit "$EXIT_USAGE"
fi
target_dir="$1"
action="${2:-check}"
lock_file="$(mktemp)"
trap 'rm -f "$lock_file"' EXIT
case "$action" in
check)
if [[ ! -d "$target_dir" ]]; then
echo "Error: no existe el directorio $target_dir" >&2
exit "$EXIT_NO_TARGET"
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
echo "Error: falta $target_dir/deploy.conf" >&2
exit "$EXIT_NO_CONFIG"
else
echo "Listo: $target_dir tiene lo necesario para desplegar."
fi
;;
clean)
for log_file in "$target_dir"/*.log; do
[[ -e "$log_file" ]] || continue # sin .log reales, el patrón queda sin expandir
echo "Borrando $log_file"
rm -f "$log_file"
done
;;
help)
usage
;;
*)
echo "Acción desconocida: $action" >&2
usage
exit "$EXIT_USAGE"
;;
esac
readonly congela cada código de salida apenas se define: si más adelante en el script alguien reasigna EXIT_USAGE por accidente, bash avisa con un error en vez de dejar que el valor cambie en silencio. ${2:-check} aplica la misma idea que ${1:-}, pero además le da un valor por defecto (check) cuando no te pasaron una segunda acción, en vez de fallar. Y en el bucle for sobre *.log, [[ -e "$log_file" ]] || continue es necesario porque, si el directorio no tiene ningún .log, bash (sin la opción nullglob, que no está activada por defecto) deja el patrón sin expandir en vez de una lista vacía — sin ese chequeo, tu script intentaría borrar un archivo literal llamado *.log que no existe.
Pruébalo con tres escenarios:
chmod +x deploy-guard.sh
./deploy-guard.sh
./deploy-guard.sh release-fantasma check
mkdir -p release && touch release/deploy.conf
./deploy-guard.sh release check
Qué esperar (revisa $? después de cada llamada si quieres confirmar el código exacto):
Error: falta el directorio del release.
Uso: ./deploy-guard.sh <directorio> {check|clean|help}
check valida que el directorio y deploy.conf existan (opción por defecto)
clean borra los .log dentro del directorio
help muestra este mensaje
Error: no existe el directorio release-fantasma
Listo: release tiene lo necesario para desplegar.
Tres llamadas, tres resultados distintos, cada uno con su propio código de salida documentado en el encabezado — exactamente lo que necesita otro script (o un pipeline de CI) para reaccionar según lo que pasó, en vez de solo ver un montón de texto y adivinar.
Cuándo el script ya creció demasiado: la frontera con Python, y shellcheck como red de seguridad
Bash es excelente para orquestar comandos existentes — llamar programas, encadenar su salida, tomar decisiones simples sobre archivos y cadenas. Deja de ser la herramienta correcta cuando el problema deja de ser "orquestar comandos" y empieza a ser "programar de verdad". Estas son señales concretas de que conviene reescribir en Python:
- El script pasa de unas 150 líneas o de un puñado de funciones — seguir el flujo a simple vista se vuelve difícil, y bash no tiene buen soporte de módulos para dividirlo en piezas más chicas.
- Necesitas estructuras de datos reales — un diccionario de host a estado, una lista de objetos con varios campos — no solo cadenas de texto y listas planas separadas por espacios.
- Necesitas parsear JSON, hacer varias llamadas HTTP con reintentos y manejo de errores, o cualquier lógica que en bash termina siendo una cadena de
curl+grep+sedcada vez más frágil. - Quieres pruebas automatizadas para tu propia lógica. Bash tiene herramientas para esto (
bats, por ejemplo), pero son mucho menos naturales que escribir un test conpytest. - La lógica ya no es "una secuencia de pasos con algunas decisiones", sino reglas de negocio con varios niveles de anidamiento, reintentos con espera creciente, o límites de tasa.
Ninguna de estas señales significa que bash esté "mal" — significa que el problema cambió de forma, y la herramienta debería cambiar con él.
Mientras tu script siga siendo bash, shellcheck es la red de seguridad más barata que existe: un analizador estático que lee tu script y te señala exactamente la línea donde algo puede fallar, con una explicación y, casi siempre, la corrección sugerida.
shellcheck deploy-guard.sh
Qué esperar si por ejemplo alguna línea del script tuviera una variable sin comillas (echo $target_dir en vez de echo "$target_dir"):
In deploy-guard.sh line 12:
echo $target_dir
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$target_dir"
Cada advertencia trae un código (SC2086 en este caso) que puedes buscar en la wiki de shellcheck si quieres el detalle completo de por qué importa. Correr shellcheck sobre cada script antes de darlo por terminado cuesta segundos y atrapa exactamente el tipo de error de comillas y variables que ya viste en esta lección y en la anterior.
Errores comunes
"Con set -euo pipefail activado, ya no me tengo que preocupar de nada — cualquier fallo va a detener el script." Qué pasa: local result="$(comando_que_falla)" no detiene el script aunque comando_que_falla termine con código distinto de cero. Por qué ocurre: bash reporta el código de salida de la sentencia completa local var=valor, que es el de local mismo (siempre 0 si la declaración es válida), no el del comando de la sustitución que hay adentro — el fallo real queda enmascarado antes de que set -e tenga oportunidad de reaccionar. Cómo detectarlo: si un script con set -e sigue corriendo después de un comando que sabes que debería haber fallado, revisa si esa asignación está combinada con local (o export, o declare) en la misma línea. Cómo corregirlo: separa la declaración de la asignación en dos líneas (local result y luego result="$(comando)" aparte), para que el código de salida del comando no quede tapado.
"Metí el while read dentro de una tubería para no tener que guardar un archivo intermedio, y mi contador salió en cero al final." Qué pasa: cat archivo | while read -r linea; do count=$((count + 1)); done incrementa count correctamente adentro del bucle, pero afuera del bucle vuelve a valer lo que valía antes (típicamente 0, o directamente falla si tenías set -u). Por qué ocurre: cada eslabón de una tubería corre en su propia subshell — una copia del proceso del shell, con sus propias variables, que desaparece apenas termina esa etapa de la tubería. El while sí ve y modifica su copia de count mientras corre, pero esa copia nunca vuelve al shell principal. Cómo detectarlo: si una variable que modificas adentro de un while read | while parece "resetearse" justo después del done, sospecha de una tubería antes del bucle. Cómo corregirlo: alimenta el while con redirección de archivo (done < archivo) en vez de una tubería — así el while corre en el mismo shell que el resto del script, exactamente como en el ejemplo trabajado de la sección de bucles de esta lección.
"Comparé dos cadenas con [[ "$var" == $esperado ]] sin comillas en el lado derecho, y dio un resultado que no esperaba." Qué pasa: si $esperado contiene un carácter especial de patrón como * o ?, [[ ]] no hace una comparación de texto literal — hace coincidencia de patrones (glob), la misma lógica que usa bash para expandir *.log. Por qué ocurre: adentro de [[ ]], el lado derecho de == (y de !=) se trata como un patrón cuando no está entre comillas, no como texto fijo. Cómo detectarlo: la comparación da un resultado inesperado justo cuando el valor contiene *, ? o [ — con texto "normal" nunca lo notas, porque no hay caracteres de patrón que interpretar. Cómo corregirlo: pon el lado derecho entre comillas ([[ "$var" == "$esperado" ]]) cada vez que quieras una comparación de texto literal en vez de coincidencia de patrones.
Ejercicios
1. Cuatro pruebas, un solo script
Escribe backup-check.sh <directorio> con estos códigos de salida documentados en un comentario de encabezado: 0 todo en orden, 2 falta el argumento, 3 el directorio no existe, 4 no existe backup.sh adentro, 5 backup.sh existe pero no tiene permiso de ejecución. Usa if/elif/else con -d, -f y -x.
Ver solución
#!/usr/bin/env bash
#
# backup-check.sh — valida que un directorio tenga un backup.sh ejecutable.
# Uso: ./backup-check.sh <directorio>
# Códigos de salida: 0 ok, 2 falta argumento, 3 no existe el directorio,
# 4 falta backup.sh, 5 backup.sh no es ejecutable.
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Error: falta el directorio a validar." >&2
echo "Uso: $0 <directorio>"
exit 2
fi
target_dir="$1"
if [[ ! -d "$target_dir" ]]; then
echo "Error: no existe el directorio $target_dir" >&2
exit 3
elif [[ ! -f "$target_dir/backup.sh" ]]; then
echo "Error: falta $target_dir/backup.sh" >&2
exit 4
elif [[ ! -x "$target_dir/backup.sh" ]]; then
echo "Error: $target_dir/backup.sh existe pero no es ejecutable" >&2
exit 5
else
echo "OK: $target_dir tiene un backup.sh ejecutable."
fi
Por qué funciona: cada elif solo se evalúa si el anterior fue falso, así que las cuatro condiciones se revisan en orden de especificidad creciente (primero que exista el directorio, luego que exista el archivo adentro, luego que sea ejecutable), y cada una sale con un código distinto documentado en el encabezado — quien llame al script puede reaccionar distinto según cuál de los cuatro números recibió.
2. Un menú con case
Escribe service-ctl.sh {start|stop|status|help} que imprima un mensaje distinto para start, stop y status, muestre el uso para help, y para cualquier otra entrada imprima un error a stderr con la acción recibida, muestre el uso, y termine con código 2.
Ver solución
#!/usr/bin/env bash
#
# service-ctl.sh — controla un servicio ficticio por nombre de acción.
# Uso: ./service-ctl.sh {start|stop|status|help}
set -euo pipefail
usage() {
echo "Uso: $0 {start|stop|status|help}"
}
action="${1:-}"
case "$action" in
start)
echo "Iniciando servicio..."
;;
stop)
echo "Deteniendo servicio..."
;;
status)
echo "El servicio está corriendo."
;;
help)
usage
;;
*)
echo "Acción desconocida: $action" >&2
usage
exit 2
;;
esac
Por qué funciona: case compara $action contra cada patrón en orden y salta a la primera rama que coincide, sin evaluar las demás; el comodín * atrapa cualquier valor que no haya coincidido con ninguna de las tres acciones válidas ni con help, exactamente como un else final pero sin necesidad de una cadena de comparaciones explícitas.
3. El fallo que set -e no detuvo
Este fragmento corre con set -euo pipefail activo y, contra lo que esperarías, imprime "Reporte generado" aunque generate_report falle:
generate_report() {
local output="$(curl -sf https://api.example.com/report)"
echo "Reporte generado"
}
¿Por qué pasa esto, y cómo lo corriges?
Ver solución
El código de salida que bash observa para esa línea es el de local, no el de curl dentro de la sustitución de comando. local var=valor es una sola sentencia para bash, y local en sí mismo tiene éxito (código 0) siempre que la variable se pueda declarar — sin importar si el comando que generó valor falló. El fallo de curl -sf (que devuelve distinto de cero si el servidor responde con error) queda enmascarado, y set -e nunca se entera.
La corrección es separar declaración y asignación:
generate_report() {
local output
output="$(curl -sf https://api.example.com/report)"
echo "Reporte generado"
}
Por qué funciona: al mover la asignación a su propia línea, su código de salida ya no compite con el de local — es el código de salida de esa sentencia completa, y set -e sí lo detecta y detiene el script antes de llegar al echo.
4. El contador que se resetea
Este fragmento imprime Total: 0 sin importar cuántas líneas tenga servers.txt:
count=0
cat servers.txt | while read -r server; do
count=$((count + 1))
done
echo "Total: $count"
¿Por qué pasa esto, y cómo lo corriges sin agregar ningún archivo intermedio nuevo?
Ver solución
Cada comando de una tubería (cat servers.txt | while ...) corre en su propia subshell — una copia separada del proceso de bash. El while sí incrementa su copia de count correctamente mientras procesa cada línea, pero esa copia vive y muere adentro de la subshell de la tubería; el count del shell principal, el que lee el echo final, nunca se entera de esos cambios y sigue valiendo 0.
La corrección es reemplazar la tubería por redirección de archivo, para que el while corra en el mismo shell que el resto del script:
count=0
while read -r server; do
count=$((count + 1))
done < servers.txt
echo "Total: $count"
Por qué funciona: < servers.txt alimenta el bucle directamente, sin crear una subshell nueva — el while corre en el shell principal, así que cada count=$((count + 1)) modifica la misma variable que el echo final va a leer.
Resumen y siguiente paso
Antes de avanzar deberías poder:
- escribir
if/elif/elsecon[[ ]]y elegir la prueba correcta (-f,-d,-z,-x,-eq,==) según el tipo de dato que estás comparando; - construir un menú de opciones con
case, sus;;y su comodín*; - iterar con
forsobre una lista o un patrón de archivos, y leer un archivo línea por línea conwhile readsin caer en la trampa de la subshell de una tubería; - terminar un script con
exity un código propio, documentado en un comentario, en vez de dejar que bash decida por defecto; - explicar qué previene cada bandera de
set -euo pipefail, y describir de memoria el caso real en que-eno te protege (una variablelocalasignada con una sustitución de comando que falla); - usar
trap ... EXITpara garantizar que un archivo temporal se borre sin importar cómo termine el script, y explicar por qué${1:-}es más seguro que$1a secas bajoset -u; - reconocer al menos tres señales concretas de que un script ya creció demasiado para bash, y correr
shellcheckcomo primera línea de defensa mientras siga siendo bash.
Todo lo que acabas de practicar — validación de argumentos, set -euo pipefail, trap, y un código de salida documentado por tipo de fallo — es exactamente el esqueleto que vas a usar en la próxima lección para construir los tres scripts del proyecto final: check-env.sh, probe.sh y remote-report.sh. Ninguno de los tres necesita lógica nueva; son estos mismos patrones aplicados a un problema real de diagnóstico remoto, con SSH, rsync y los comandos de red que ya viste antes en este módulo.
Recursos
- The Set Builtin — GNU Bash Reference Manual — documentación oficial de
-e,-uy-o pipefail, directo de la fuente. - Bash Conditional Expressions — GNU Bash Reference Manual — la lista completa de pruebas que acepta
[[ ]], incluidas-f,-d,-zy-x. - Looping Constructs — GNU Bash Reference Manual — sintaxis oficial de
forywhile. - BashFAQ/105 — Why doesn't set -e do what I expected? — la fuente exacta del caso de
local var=$(comando)enmascarando un fallo, con más ejemplos de los límites deset -e. - BashPitfalls — Greg's Wiki — catálogo de errores comunes de bash, incluida la subshell de
comando | while read(pitfall #8) y la coincidencia de patrones sin comillas en[[ ]](pitfall #34). - ShellCheck — analizador estático de scripts de shell; corre
shellcheck archivo.shen tu terminal o pega el script en la versión web para ver las advertencias explicadas.