Curso de Bash desde cero / Capítulo 13
Scripts que no se rompen
El capítulo 9 te explicó por qué fallan los scripts: comillas, expansión y palabras que se parten donde no debían. Este capítulo va de lo que se hace con eso: conseguir que un script, cuando falle, falle pronto y se note, en vez de seguir adelante haciendo daño.
Es la diferencia entre un script que usas tú una tarde y uno que dejas programado en cron y no vuelves a mirar.
El script que hace daño en silencio
Mira este script de cuatro líneas. Su idea es entrar en una carpeta de trabajo y borrar los archivos temporales:
#!/bin/bash
cd /carpeta/que/no/existe
rm -f *.tmp
echo "Limpieza terminada"Guárdalo como malo.sh. Ahora crea una carpeta con cosas
dentro y ejecútalo desde ahí:
mkdir zona && cd zona
touch a.tmp b.tmp datos.txt
ls
bash ../malo.sh
echo "código de salida: $?"
lsY sale:
a.tmp
b.tmp
datos.txt
../malo.sh: line 2: cd: /carpeta/que/no/existe: No such file or directory
Limpieza terminada
código de salida: 0
datos.txtLee eso despacio, porque han pasado tres cosas y las tres son malas:
- El
cdfalló. Lo dijo, pero nadie lo escuchó. - Como el
cdno funcionó, el script seguía en tu carpeta. Y elrm -f *.tmpse ejecutó ahí: ha borradoa.tmpyb.tmp, que no era lo que querías. - El script terminó diciendo «Limpieza terminada» y devolviendo código 0, es decir, éxito. Si esto estuviera en cron, no te enterarías nunca.
Un error se convirtió en un borrado en el sitio equivocado, y encima se informó como si todo hubiera ido bien. Todo el capítulo va de evitar esto.
Las tres líneas mágicas: set -euo pipefail
La primera medida cabe en una línea, y es la que más problemas evita:
#!/bin/bash
set -euo pipefailSon tres opciones independientes. Conviene entender qué hace cada una por separado, porque ninguna es magia.
-e: parar al primer error
Con set -e, si un comando devuelve un código distinto de
cero, el script termina ahí mismo.
#!/bin/bash
set -e
cd /carpeta/que/no/existe
rm -f *.tmp
echo "Limpieza terminada"Al ejecutarlo:
bueno.sh: line 3: cd: /carpeta/que/no/existe: No such file or directory
código de salida: 1El rm no llegó a ejecutarse, no se borró nada, y el
script devolvió 1. Con una sola línea, el desastre de antes no
ocurre.
-u: variable sin definir es un error
Por defecto, una variable que no existe se sustituye por nada, en silencio. Y eso, combinado con rutas, es peligroso:
#!/bin/bash
destino="/tmp/copia_seguridad"
echo "Borrando $destino/$carpeta"Al ejecutarlo:
Borrando /tmp/copia_seguridad/La variable carpeta no existía (a lo mejor la escribiste
Carpeta en otro sitio), así que la ruta se quedó a medias.
Cambia ese echo por un rm -rf e imagina el
resultado.
Con set -u, Bash se planta:
u2.sh: line 4: carpeta: unbound variable
código de salida: 1Consejo
set -ues el que más erratas caza, porque los nombres de variable mal escritos no dan ningún síntoma hasta que es tarde.
-o pipefail: que un pipe no mienta
Esta es la más sutil. Cuando encadenas comandos con |,
Bash toma como código de salida el del último comando,
no el de todos. Así que si falla el primero pero el último va bien, el
pipe entero parece un éxito:
#!/bin/bash
set -e
cat /fichero/inexistente | wc -l
echo "El pipe fue bien, sigo adelante"Al ejecutarlo:
cat: /fichero/inexistente: No such file or directory
0
El pipe fue bien, sigo adelante
código de salida: 0cat falló, pero wc -l contó cero líneas y
terminó tan contento. Ni siquiera set -e lo detiene, porque
para Bash el pipe fue bien.
Con pipefail, el pipe falla si
cualquiera de sus comandos falla:
#!/bin/bash
set -e -o pipefail
cat /fichero/inexistente | wc -l
echo "El pipe fue bien, sigo adelante"Al ejecutarlo:
cat: /fichero/inexistente: No such file or directory
0
código de salida: 1Como en este curso todo se hace a base de pipes (y el capítulo 12 va
entero de eso), pipefail importa más de lo que parece.
Cuándo -e no te salva
Aquí viene la parte honesta, que casi nadie cuenta:
set -e tiene agujeros. Si crees que te
protege siempre, te confiarás justo donde no debes.
set -e no detiene el script en estos
cuatro casos:
1. Dentro de la condición de un if. Es lógico: un
if existe precisamente para comprobar si algo falla.
#!/bin/bash
set -e
if grep "nada" /fichero/inexistente; then
echo "encontrado"
else
echo "no encontrado, pero el script SIGUE VIVO"
fi2. A la izquierda de un &&.
#!/bin/bash
set -e
false && echo "esto no se ve"
echo "y aquí seguimos, aunque false ha fallado"3. Cuando tú mismo capturas el fallo con
||. Esto no es un agujero, es lo que quieres: le
estás diciendo a Bash que ya te ocupas tú.
cat /fichero/inexistente || echo "lo he capturado yo"4. Dentro de una función que se llama desde un
if. Este es el peligroso de verdad:
#!/bin/bash
set -e
comprobar() {
false
echo "OJO: esta línea se ejecuta"
}
if comprobar; then echo "fue bien"; else echo "falló"; fiAl ejecutarlo:
OJO: esta línea se ejecuta
fue bienEl false no detuvo la función, la función siguió, y
encima devolvió éxito (porque el código de salida de una
función es el de su última orden, que era el echo). Dentro
de un if, set -e queda desactivado en
todo lo que haya debajo.
Precaución
Moraleja:
set -euo pipefailes la primera línea de defensa, no la única. Cuando algo es importante de verdad —borrar, copiar sobre otra cosa, subir a un servidor— compruébalo tú explícitamente en vez de dar por hecho que el script se habría parado.
Valores por defecto
Un script útil suele tener parámetros opcionales. La expansión de
parámetros resuelve eso sin escribir un if por cada
uno.
| Escritura | Qué hace |
|---|---|
${var:-valor} |
Usa valor si var está vacía o no existe.
No la modifica |
${var:=valor} |
Igual, pero además guarda ese valor en
var |
${var:?mensaje} |
Si está vacía, aborta el script con ese mensaje |
${var:+otro} |
Al revés: usa otro solo si
var tiene algo |
destino="${1:-/tmp/copias}" # si no pasan argumento, /tmp/copias
echo "Voy a copiar a $destino"El tercero es el que conviene conocer para las cosas que no son opcionales. En vez de escribir la comprobación, la exiges:
#!/bin/bash
: "${API_TOKEN:?hace falta definir API_TOKEN}"
echo "el token es $API_TOKEN"Al ejecutarlo:
tok.sh: line 2: API_TOKEN: hace falta definir API_TOKEN
código de salida: 1Y con la variable puesta, funciona:
API_TOKEN=abc123 bash tok.shY sale:
el token es abc123Nota
Los dos puntos sueltos del principio (
:) son un comando de Bash que no hace nada. Se usa aquí solo como percha para colgar la expansión: nos interesa el efecto (comprobar y abortar), no imprimir nada.
Recortar cadenas
La misma sintaxis de las llaves sirve para trocear texto sin llamar a
sed ni a cut. Es más rápido y no monta un pipe
por cada archivo.
| Escritura | Qué hace |
|---|---|
${#var} |
Cuántos caracteres tiene |
${var%patron} |
Quita el patrón por el final (el trozo más corto) |
${var%%patron} |
Igual, pero el trozo más largo |
${var#patron} |
Quita el patrón por el principio (el más corto) |
${var##patron} |
Igual, pero el más largo |
${var/viejo/nuevo} |
Sustituye la primera aparición |
${var//viejo/nuevo} |
Sustituye todas |
Regla para acordarse de cuál es cuál: en un teclado, #
está a la izquierda del $ y quita por la izquierda;
% está a la derecha y quita por la derecha.
ruta="/home/nica/documentos/informe.txt"
nombre="informe.txt"
echo "${#nombre}" # 11
echo "${nombre%.txt}" # informe
echo "${nombre%.*}" # informe (cualquier extensión)
echo "${ruta##*/}" # informe.txt (solo el archivo)
echo "${ruta%/*}" # /home/nica/documentos (solo la carpeta)
frase="uno dos uno dos"
echo "${frase/uno/UNO}" # UNO dos uno dos
echo "${frase//uno/UNO}" # UNO dos UNO dosEl uso típico es renombrar en bloque:
for f in *.txt; do
mv -- "$f" "${f%.txt}.md"
doneFíjate en las comillas de "${f%.txt}.md": sin ellas, un
archivo llamado mis apuntes.txt volvería a romper el
script, exactamente como en el capítulo 9. Y el -- le dice
a mv que lo que viene después son nombres de archivo,
aunque empiecen por guion.
Arrays
Un array es una variable que guarda una lista de valores en vez de uno solo. Es lo que necesitas cuando manejas «los servidores», «los archivos a copiar» o «las carpetas a revisar».
servidores=("web1" "base de datos" "correo")
echo "${#servidores[@]}" # cuántos hay: 3
echo "${servidores[0]}" # el primero: web1 (se empieza a contar en 0)
servidores+=("respaldo") # añadir uno al finalPara recorrerlo, for con "${array[@]}".
Y las comillas no son opcionales:
for s in "${servidores[@]}"; do
echo "[$s]"
doneY sale:
[web1]
[base de datos]
[correo]Quítalas y vuelve el word splitting del capítulo 9: el elemento «base de datos» se parte en tres.
for s in ${servidores[@]}; do
echo "[$s]"
doneY sale:
[web1]
[base]
[de]
[datos]
[correo]Precaución
"${array[@]}"con comillas y con@es la única forma correcta de recorrer un array. Con*en vez de@se junta todo en una sola cadena, y sin comillas se parte por los espacios. Apréndete la forma buena y no le des más vueltas.
Si además necesitas la posición de cada elemento,
"${!array[@]}" da los índices:
for i in "${!servidores[@]}"; do
echo "$i -> ${servidores[$i]}"
doneLimpiar al salir: trap
Muchos scripts crean archivos temporales. El problema es qué pasa con ellos cuando el script muere a mitad, o cuando pulsas Ctrl+C.
Primero, la forma correcta de crear un temporal es
mktemp, que se inventa un nombre único (dos ejecuciones a
la vez no se pisan):
tmp="$(mktemp)"Y ahora el problema. Este script no llega a borrar su temporal:
#!/bin/bash
set -euo pipefail
tmp="$(mktemp)"
echo "datos" > "$tmp"
cat /fichero/inexistente # aquí muere
rm -f "$tmp" # esta línea no se ejecuta nuncaEl temporal se queda en /tmp para siempre. Repite eso
cada hora en cron y en un mes tienes miles.
trap resuelve esto: registra una orden para que Bash la
ejecute pase lo que pase cuando el script termine.
#!/bin/bash
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
echo "datos" > "$tmp"
cat /fichero/inexistenteAhora el script sigue fallando (eso está bien, queremos que falle), pero el temporal desaparece igualmente.
La señal EXIT significa «cuando el script termine, por
el motivo que sea»: final normal, exit, error con
set -e o Ctrl+C. Por eso es la que se usa el 99% de las
veces.
| Señal | Cuándo salta |
|---|---|
EXIT |
Siempre que el script termina, sea como sea |
INT |
Ctrl+C |
TERM |
Cuando alguien lo mata con kill |
ERR |
Cuando un comando falla (útil para mensajes de diagnóstico) |
Nota
El
trapse escribe entre comillas simples a propósito. Así la orden se guarda tal cual y$tmpse sustituye cuando el trap se ejecuta, no cuando se registra. Con comillas dobles funcionaría también en este caso, pero se rompe en cuanto la variable cambie durante el script.
Comprobar antes de hacer
Ya tienes las piezas. Falta el hábito: validar al principio y salir con un mensaje claro. Este patrón de tres líneas es el que uso en todos los scripts:
morir() {
echo "Error: $1" >&2
exit 1
}
[ "$#" -eq 2 ] || morir "uso: $(basename "$0") ORIGEN DESTINO"
[ -d "$1" ] || morir "el origen '$1' no es una carpeta"Dos detalles importantes:
- El
>&2manda el mensaje al canal de errores (el capítulo 4 explicaba los tres canales). Así, si alguien redirige la salida del script a un archivo, los errores siguen viéndose en pantalla. - El
exit 1devuelve un código de error. Es lo que permite que quien llame a tu script —otro script, o cron— se entere de que falló.
Error: uso: val.sh ORIGEN DESTINO
código de salida: 1ShellCheck
ShellCheck es un programa que lee tus scripts y te avisa de los fallos antes de ejecutarlos. Detecta justo lo de este capítulo y el 9: comillas que faltan, variables mal escritas, comparaciones que no hacen lo que crees.
sudo apt install shellcheck
shellcheck mi-script.shVamos a pasárselo a un script con los fallos típicos:
#!/bin/bash
origen=$1
destino=$2
for archivo in $(ls $origen); do
cp $origen/$archivo $destino
done
echo "Copiados $numero archivos"Este script parece correcto y funciona mientras las carpetas no tengan espacios. Esto es lo que dice ShellCheck:
In copiar.sh line 5:
for archivo in $(ls $origen); do
^-----------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
In copiar.sh line 6:
cp $origen/$archivo $destino
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
^------^ SC2086 (info): Double quote to prevent globbing and word splitting.
^------^ SC2086 (info): Double quote to prevent globbing and word splitting.
In copiar.sh line 9:
echo "Copiados $numero archivos"
^-----^ SC2154 (warning): numero is referenced but not assigned.Ha encontrado las tres cosas: que recorrer la salida de
ls es frágil (mejor un comodín), las cinco variables sin
comillas del capítulo 9, y una variable numero que nunca se
asigna —una errata que habría impreso «Copiados archivos» sin más—.
Además te da la dirección de una página que explica cada aviso con
detalle.
Si usas VS Code, hay una extensión que lo va marcando mientras escribes, que es todavía más cómodo.
No sustituye a entender lo que hace tu script, pero encuentra en dos segundos cosas que de otra forma descubres dentro de un mes, un domingo, cuando la tarea de cron lleva semanas fallando en silencio.
El script del principio, arreglado
Volvamos al script de la primera sección y apliquemos todo:
#!/bin/bash
set -euo pipefail
morir() {
echo "Error: $1" >&2
exit 1
}
[ "$#" -eq 1 ] || morir "uso: $(basename "$0") CARPETA"
carpeta="$1"
[ -d "$carpeta" ] || morir "'$carpeta' no es una carpeta"
cd "$carpeta"
archivos=( *.tmp )
if [ ! -e "${archivos[0]}" ]; then
echo "No hay temporales que borrar en $(pwd)"
exit 0
fi
echo "Voy a borrar ${#archivos[@]} archivos de $(pwd)"
rm -f -- "${archivos[@]}"
echo "Limpieza terminada"Compara los dos comportamientos:
--- sin argumento ---
Error: uso: limpiar.sh CARPETA
código de salida: 1
--- carpeta inexistente ---
Error: '/no/existe' no es una carpeta
código de salida: 1
--- carpeta sin temporales ---
No hay temporales que borrar en /home/nica/pruebas/vacia
código de salida: 0
--- carpeta con temporales ---
Voy a borrar 2 archivos de /home/nica/pruebas/zona
Limpieza terminada
código de salida: 0Nunca borra en la carpeta equivocada, dice dónde va a borrar y cuántos archivos antes de hacerlo, distingue «no había nada que borrar» (que no es un error) de «me has dado una carpeta que no existe» (que sí lo es), y funciona con nombres que llevan espacios.
Consejo
El
archivos=( *.tmp )seguido de[ ! -e "${archivos[0]}" ]resuelve una trampa clásica: cuando un comodín no encuentra nada, Bash deja el*.tmpliteral en vez de una lista vacía. Comprobar si el primer elemento existe de verdad es la forma de distinguir «no hay archivos» de «hay uno que se llama así».
Ejercicios
Coge un script cualquiera de los capítulos anteriores, añádele
set -euo pipefaily ejecútalo. Si deja de funcionar, averigua por qué: casi siempre es una variable sin definir que llevaba ahí desde el principio.Escribe un script que reciba un nombre de archivo y muestre por separado su nombre sin extensión y su extensión, usando solo expansión de parámetros (nada de
cutnised).Escribe un script que reciba una o más carpetas como argumentos, las guarde en un array y muestre cuántos archivos hay en cada una. Debe funcionar con carpetas cuyo nombre tenga espacios.
Añade a ese script un archivo temporal creado con
mktempy untrapque lo borre al salir. Comprueba que el temporal desaparece incluso si interrumpes el script con Ctrl+C.
Soluciones
1. El fallo más habitual es set -u
protestando por $1 cuando el script se ejecuta sin
argumentos. Se arregla dándole un valor por defecto:
archivo="${1:-}" # ahora está definida, aunque esté vacía2. Para el nombre, %.* quita desde el
último punto hacia la derecha. Para la extensión, ##*.
quita todo hasta el último punto, que es justo lo contrario. Con
copia.tar.gz dan copia.tar y
gz:
#!/bin/bash
set -euo pipefail
[ "$#" -eq 1 ] || { echo "uso: $(basename "$0") ARCHIVO" >&2; exit 1; }
archivo="$1"
echo "nombre: ${archivo%.*}"
echo "extensión: ${archivo##*.}"3. Los argumentos se meten en un array con
("$@"), con comillas:
#!/bin/bash
set -euo pipefail
[ "$#" -ge 1 ] || { echo "uso: $(basename "$0") CARPETA..." >&2; exit 1; }
carpetas=("$@")
for c in "${carpetas[@]}"; do
if [ -d "$c" ]; then
cuantos=$(find "$c" -maxdepth 1 -type f | wc -l)
echo "$c: $cuantos archivos"
else
echo "$c: no es una carpeta" >&2
fi
done4. Basta con añadir dos líneas al principio. El
trap se registra justo después de crear el
temporal, no antes (si no, aún no existe la variable) ni mucho después
(si no, hay un hueco en el que puede morir sin limpiar):
#!/bin/bash
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
for c in "$@"; do
find "$c" -maxdepth 1 -type f >> "$tmp"
done
echo "Total de archivos: $(wc -l < "$tmp")"