detomas.net Curso de Bash desde cero
Descargar PDF

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: $?"
ls

Y 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.txt

Lee eso despacio, porque han pasado tres cosas y las tres son malas:

  1. El cd falló. Lo dijo, pero nadie lo escuchó.
  2. Como el cd no funcionó, el script seguía en tu carpeta. Y el rm -f *.tmp se ejecutó ahí: ha borrado a.tmp y b.tmp, que no era lo que querías.
  3. 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 pipefail

Son 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: 1

El 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: 1

Consejo

set -u es 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: 0

cat 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: 1

Como 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"
fi

2. 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ó"; fi

Al ejecutarlo:

OJO: esta línea se ejecuta
fue bien

El 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 pipefail es 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: 1

Y con la variable puesta, funciona:

API_TOKEN=abc123 bash tok.sh

Y sale:

el token es abc123

Nota

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 dos

El uso típico es renombrar en bloque:

for f in *.txt; do
    mv -- "$f" "${f%.txt}.md"
done

Fí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 final

Para recorrerlo, for con "${array[@]}". Y las comillas no son opcionales:

for s in "${servidores[@]}"; do
    echo "[$s]"
done

Y 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]"
done

Y 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]}"
done

Limpiar 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 nunca

El 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/inexistente

Ahora 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 trap se escribe entre comillas simples a propósito. Así la orden se guarda tal cual y $tmp se 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:

Error: uso: val.sh ORIGEN DESTINO
código de salida: 1

ShellCheck

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.sh

Vamos 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: 0

Nunca 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 *.tmp literal 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

  1. Coge un script cualquiera de los capítulos anteriores, añádele set -euo pipefail y ejecútalo. Si deja de funcionar, averigua por qué: casi siempre es una variable sin definir que llevaba ahí desde el principio.

  2. 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 cut ni sed).

  3. 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.

  4. Añade a ese script un archivo temporal creado con mktemp y un trap que 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ía

2. 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
done

4. 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")"