Llevas cinco lecciones escribiendo set -euo pipefail sin saber qué hace. Has puesto dos veces un return 0 al final de log() «porque si no, el script muere». Y informe_reservas.sh crea el directorio del informe y, si algo falla a mitad de escribirlo, deja un fichero incompleto que mañana alguien leerá como si fuera bueno. Todo eso se arregla hoy, y conviene entender por qué importa tanto: un script que se rompe pronto y hace ruido es infinitamente mejor que uno que falla a medias en silencio. El primero te despierta a las cuatro de la mañana con un mensaje claro; el segundo te deja una copia truncada que descubrirás el día que la necesites. Esta lección va de conseguir lo primero: ver qué hace tu script, hacer que se detenga cuando debe y garantizar que deja las cosas limpias pase lo que pase. Al final escribiremos por fin respaldo_tramontana.sh.

Contenido

  1. Depuración: ver qué hace realmente el script
  2. shellcheck en serio
  3. El modo estricto desmenuzado
  4. Dónde set -e no te salva
  5. trap y las pseudo-señales
  6. Limpieza garantizada y Ctrl+C
  7. Un manejador ERR que sirva de algo
  8. Idempotencia
  9. Reintentos, temporales seguros y bloqueo
  10. Aplicación: respaldo_tramontana.sh

  1. Depuración: ver qué hace realmente el script

Herramienta Qué hace
bash -n script.sh Comprueba la sintaxis sin ejecutar nada
bash -x script.sh Traza cada comando ya expandido, a stderr
set -x / set +x / PS4 Activa y desactiva la traza por zonas / da formato al prefijo
set -v Muestra las líneas antes de expandirlas

bash -x es la herramienta principal, y su salida por defecto (+ comando) es poco útil en cuanto hay funciones. Con un PS4 decente cambia por completo:

operador@srv-tramontana:~$ PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \
>     bash -x ~/scripts/purgar_releases.sh -n 2>&1 | head -5
+ purgar_releases.sh:12:main: DRY_RUN=0
+ purgar_releases.sh:20:main: readlink -f /opt/tramontana/app
+ purgar_releases.sh:20:main: basename /opt/tramontana/releases/3.2.1
+ purgar_releases.sh:20:main: activo=3.2.1
+ purgar_releases.sh:24:main: lista+=(3.1.0)

Cada línea dice ahora fichero, número de línea y función, y muestra el comando después de expandir variables: ves basename /opt/tramontana/releases/3.2.1, no basename "$(readlink -f "$BASE/app")". Esa diferencia es justo lo que buscas cuando una variable no vale lo que creías. Trazar un script entero genera cientos de líneas, así que para acotar rodea solo la zona sospechosa con set -x … set +x. Exporta además el PS4 en tu ~/.bashrc para no escribirlo cada vez. set -v se usa mucho menos, pero es el complemento de -x cuando el problema está en la expansión misma: -v enseña lo que escribiste y -x lo que Bash entendió.

  1. shellcheck en serio

Ya lo instalaste en 04-01. Estos son los avisos que más vas a ver, con su porqué:

Código Qué dice Por qué importa de verdad
SC2086 / SC2046 «Entrecomilla para evitar globbing y división de palabras», para variables y para $(comando) rm $f con f="informe agosto.txt" borra dos ficheros equivocados; con f="*" borra todo
SC2164 «Usa cd ... || exit» Si el cd falla, lo que venga después se ejecuta en el directorio equivocado
SC2155 «Declara y asigna por separado» local x=$(cmd) devuelve el código de local, siempre 0: el fallo de cmd se pierde

SC2155 merece demostración, porque es sutil y aparece en todas partes:

operador@srv-tramontana:~$ f() { local v=$(false); echo "dentro: $?"; }; f
dentro: 0
operador@srv-tramontana:~$ g() { local v; v=$(false); echo "dentro: $?"; }; g
dentro: 1

En el primero el error de false desaparece y, con set -e, el script continúa alegremente con una variable vacía; en el segundo se detecta. La regla: declara primero, asigna después.

Silenciar un aviso es legítimo cuando sabes lo que haces, pero siempre con justificación escrita, y la directiva afecta solo a la línea siguiente (si la pones al principio del fichero silencia el aviso en todo el script, que casi nunca es lo que quieres):

# Queremos que OPCIONES se divida en palabras: son opciones separadas.
# shellcheck disable=SC2086
rsync $OPCIONES "$origen" "$destino"

  1. El modo estricto desmenuzado

set -euo pipefail son tres ajustes independientes, y conviene saber exactamente qué hace cada uno.

  • set -e (errexit): si un comando devuelve un código distinto de 0, el script termina inmediatamente con ese código. Convierte los fallos silenciosos en paradas ruidosas.
  • set -u (nounset): usar una variable no definida es un error fatal en vez de expandirse a la cadena vacía. Evita el clásico rm -rf "$DIR/" convertido en rm -rf / porque DIR no existía.
  • set -o pipefail: una tubería devuelve el código del último tramo que falló, no el del último tramo; sin él, cat inexistente | wc -l devuelve 0 y tu script cree que todo fue bien.
operador@srv-tramontana:~$ bash -c 'cat /noexiste | wc -l; echo "código: $?"'
cat: /noexiste: No existe el fichero o el directorio
0
código: 0
operador@srv-tramontana:~$ bash -c 'set -o pipefail
> cat /noexiste | wc -l; echo "código: $?"'
cat: /noexiste: No existe el fichero o el directorio
0
código: 1

Sobre set -u, dos detalles prácticos. Con Bash 4.4 y posteriores —incluido el 5.2 de tu Ubuntu 24.04— "$@" y "${array[@]}" vacíos ya no disparan el error, pero ${array[0]} de un array vacío sí. Y la salida de emergencia para cualquier variable que legítimamente pueda no existir es ${VAR:-}, que ya usas desde 04-02: [[ -n ${TRAMONTANA_VERBOSE:-} ]] && echo "modo detallado".

  1. Dónde set -e no te salva

Este es el material que casi nadie cuenta y que hace que la gente confíe de más en el modo estricto. set -e se desactiva en cinco contextos, y en todos ellos un fallo pasa desapercibido:

set -e
# 1. En la condición de un if, while o until: es su trabajo, no un fallo.
if grep -q algo fichero_inexistente; then :; fi     # no aborta (correcto)
# 2. A la izquierda de && o ||, y en cualquier lista de comandos.
false && echo "nada"                                # no aborta
comprobar_algo || echo "avisado"                    # no aborta
# 3. Con ! delante.
! false                                             # no aborta
# 4. DENTRO de una función llamada en una condición: se desactiva entera.
preparar() { cp /noexiste /tmp/x; echo "sigo aquí"; }
if preparar; then :; fi                             # imprime "sigo aquí"
# 5. En asignaciones tipo local/declare/export con sustitución de comandos.
mi_fn() { local v=$(false); echo "no aborta"; }

El caso 4 es el más traicionero y merece verse funcionando:

operador@srv-tramontana:~$ bash -c 'set -e
> preparar() { cp /noexiste /tmp/x; echo "SIGO EJECUTANDO"; return 0; }
> if preparar; then echo "preparado"; fi'
cp: no se puede efectuar `stat' sobre '/noexiste': No existe el fichero...
SIGO EJECUTANDO
preparado

cp falló, set -e estaba activo, y la función siguió hasta el final y declaró éxito, porque al usarla como condición Bash desactiva errexit en todo su cuerpo. Se arregla comprobando dentro (cp ... || return 1) o no usando la función como condición: llamarla suelta y dejar que set -e haga su trabajo. La conclusión práctica: set -e es una red de seguridad, no un plan. Comprueba explícitamente lo que importa —con || morir, con if— y deja que set -e atrape lo que se escape. Y suma la trampa de 04-02: (( i++ )) con i a 0 devuelve 1 y sí aborta; usa (( ++i )) o i=$(( i + 1 )).

  1. trap y las pseudo-señales

trap instala un manejador que se ejecuta al llegar una señal. Su sintaxis es trap 'comandos' SEÑAL... y, además de las señales de 03-06, admite cuatro pseudo-señales:

Pseudo-señal Se dispara…
EXIT Al terminar el script, por el motivo que sea (incluido set -e)
ERR Cada vez que un comando falla, con las mismas exclusiones que set -e
DEBUG / RETURN Antes de cada comando (ralentiza mucho) / al volver de una función

EXIT es la importante: es la única forma de garantizar que algo ocurra pase lo que pase, porque se dispara con un exit normal, con un fallo de set -e, con un Ctrl+C y con un kill; solo SIGKILL se lo salta, y contra eso no hay defensa. Tres detalles que evitan sorpresas: las comillas del manejador deciden cuándo se expanden las variables (simples, en el momento del disparo, que casi siempre es lo que quieres); trap - EXIT desinstala un manejador; y trap -p lista los instalados.

  1. Limpieza garantizada y Ctrl+C

El patrón que convierte un script en algo seguro de interrumpir:

TEMPORAL=""
limpiar() {
    local codigo=$?                    # hay que capturarlo en la PRIMERA línea
    [[ -n $TEMPORAL && -d $TEMPORAL ]] && rm -rf -- "$TEMPORAL"
    if (( codigo == 0 )); then log "terminado correctamente"
    else error "terminado con código $codigo"; fi
    exit "$codigo"                     # conserva el código original
}
trap limpiar EXIT
trap 'error "interrumpido por el usuario"; exit 130' INT TERM
TEMPORAL=$(mktemp -d) || morir 73 "no puedo crear el directorio temporal"

Cuatro decisiones deliberadas. local codigo=$? va en la primera línea, porque cualquier comando anterior lo sobrescribiría. La comprobación [[ -n $TEMPORAL && -d ... ]] protege del caso en que el script muera antes de crear el temporal: sin ella, un rm -rf "$TEMPORAL"/* con la variable vacía sería catastrófico. exit "$codigo" preserva el código original en vez de devolver el del rm. Y el manejador de INT sale con 130, que es 128 + 2 (SIGINT), la convención de 04-01; ese exit 130 dispara a su vez el EXIT, así que la limpieza se ejecuta igualmente: los manejadores se encadenan, no se pisan.

  1. Un manejador ERR que sirva de algo

Bash publica cuatro variables durante un manejador ERR, y con ellas se construye una traza casi tan útil como la de un lenguaje con excepciones:

Variable Contenido
$? / BASH_COMMAND El código del fallo / el texto del comando que fallaba
BASH_LINENO[0] / FUNCNAME[@] La línea del contexto actual / la pila de funciones
traza_error() {
    local codigo=$?
    printf '[%s] ERROR %d en %s, línea %s\n' \
        "$(date '+%F %T')" "$codigo" "${BASH_SOURCE[1]##*/}" "${BASH_LINENO[0]}" >&2
    printf '  comando : %s\n' "$BASH_COMMAND" >&2
    printf '  pila    : %s\n' "${FUNCNAME[*]:1}" >&2   # :1 salta traza_error
    return "$codigo"
}
trap traza_error ERR
operador@srv-tramontana:~$ ~/scripts/respaldo_tramontana.sh
[2026-08-18 13:07:22] ERROR 1 en respaldo_tramontana.sh, línea 61
  comando : cp -a -- /home/operador/datos/reservas.csv /srv/.../respaldo.9kQ2
  pila    : copiar_origenes main

En cuatro líneas sabes el código, el fichero, la línea, el comando exacto ya expandido y la cadena de llamadas. Compáralo con el cp: Permiso denegado a secas que habrías tenido y entenderás por qué esta función merece vivir en lib/comunes.sh.

  1. Idempotencia

Un script es idempotente si ejecutarlo dos veces deja el sistema igual que ejecutarlo una. Es el objetivo de diseño más valioso del módulo, porque significa que puedes relanzarlo tras un fallo sin pensar. El contraejemplo canónico, que Luis escribió el mes pasado:

# NO idempotente: cada ejecución añade otra línea
echo "max_conexiones=200" >> /etc/tramontana/app.conf

Tras cuatro ejecuciones, grep -c '^max_conexiones=' /etc/tramontana/app.conf devuelve 4. La aplicación lee la última y funciona, así que nadie se entera... hasta que alguien edita la primera y no pasa nada. La versión idempotente comprueba antes de actuar:

fijar_opcion() {                    # fijar_opcion FICHERO CLAVE VALOR
    local fichero="$1" clave="$2" valor="$3"
    if grep -q "^${clave}=" "$fichero"; then
        # Ya existe: se sustituye, con copia fechada como manda el Módulo 2.
        sudo sed -i.bak-"$(date +%F)" "s|^${clave}=.*|${clave}=${valor}|" "$fichero"
    else
        printf '%s=%s\n' "$clave" "$valor" | sudo tee -a "$fichero" >/dev/null
    fi
}

operador@srv-tramontana:~$ fijar_opcion /etc/tramontana/app.conf max_conexiones 200
operador@srv-tramontana:~$ fijar_opcion /etc/tramontana/app.conf max_conexiones 200
operador@srv-tramontana:~$ grep -c '^max_conexiones=' /etc/tramontana/app.conf
1

Tres ideas transferibles: mkdir -p en vez de mkdir (no falla si existe), ln -sfn en vez de ln -s (reemplaza el enlace en vez de anidarlo dentro) y comprobar el estado deseado antes de aplicar el cambio. Es el modelo con el que trabaja Ansible, y por eso su salida distingue ok de changed; volveremos a ello en 07-06.

  1. Reintentos, temporales seguros y bloqueo

Reintentos con retroceso exponencial

Una operación de red no falla: falla a veces. Reintentar de inmediato empeora las cosas, así que se espera cada vez más:

# reintentar INTENTOS ESPERA_INICIAL COMANDO...
#   Devuelve: 0 si el comando acaba bien; 1 si se agotan los intentos.
reintentar() {
    local intentos="$1" espera="$2" n=1; shift 2
    until "$@"; do
        (( n >= intentos )) && { error "fallo tras $n intentos: $*"; return 1; }
        log "intento $n falló; reintento en ${espera}s"
        sleep "$espera"
        espera=$(( espera * 2 )); (( ++n ))
    done
}
operador@srv-tramontana:~$ reintentar 4 2 curl -sf http://10.0.2.15:8080/salud
[2026-08-18 13:11:02] intento 1 falló; reintento en 2s
[2026-08-18 13:11:04] intento 2 falló; reintento en 4s

Las esperas van 2, 4, 8, 16: eso es el retroceso exponencial. Y no reintentes nunca operaciones no idempotentes —crear una reserva, enviar un correo— sin un identificador que evite duplicarlas.

Temporales seguros

TMP="/tmp/respaldo_$$" es un agujero de seguridad real: el PID es predecible, así que un atacante puede crear de antemano un enlace simbólico con ese nombre apuntando a /etc/tramontana/app.conf, y tu script, si corre con privilegios, sobrescribirá el fichero apuntado. TMP=$(mktemp -d) || morir 73 "sin temporal" lo evita porque crea el fichero de forma atómica (con O_EXCL, que falla si ya existe), con nombre aleatorio y permisos 600 —700 para directorios—. Úsalo siempre, junto al trap ... EXIT del apartado 6.

Bloqueo dentro del script

En 03-07 pusiste flock -n delante del comando en la línea de crontab. Mejor que el bloqueo viva dentro del script, para que proteja también cuando lo lances a mano:

exec 9>"$BLOQUEO" || morir 73 "no puedo abrir el bloqueo" seguido de flock -n 9 || morir 75 "ya hay otra copia en marcha". exec 9>fichero abre el descriptor 9 para todo el script y flock -n 9 intenta bloquearlo sin esperar. El bloqueo se libera solo cuando el proceso muere, incluso a golpe de kill -9, porque lo mantiene el kernel y no el fichero. No borres el fichero de bloqueo al terminar: abre una carrera en la que dos procesos podrían bloquear ficheros distintos con el mismo nombre.

  1. Aplicación: respaldo_tramontana.sh

Prometido desde el Módulo 2, con todo lo de esta lección:

#!/usr/bin/env bash
#
# respaldo_tramontana.sh - Copia de seguridad de la configuración, los datos
#                          y el release activo de Tramontana Reservas.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18
# Uso   : respaldo_tramontana.sh [-h] [-v] [-n] [-d DESTINO]
# Salida: 0 ok | 2 uso | 66 origen ilegible | 69 falta una dependencia
#         73 no se puede escribir | 75 ya hay otra copia | 130 interrumpido

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"
readonly BLOQUEO="/var/lock/tramontana-copia.lock"
readonly ENLACE_APP="/opt/tramontana/app"
readonly ORIGENES=(/home/operador/datos/reservas.csv /etc/tramontana/app.conf)
DESTINO="${TRAMONTANA_DESTINO:-/srv/tramontana/backups/envios}"
DRY_RUN=0; TEMPORAL=""

limpiar() {                       # el manejador del apartado 6, tal cual
    local codigo=$?
    [[ -n $TEMPORAL && -d $TEMPORAL ]] && rm -rf -- "$TEMPORAL"
    (( codigo == 0 )) && log "copia terminada correctamente"
    exit "$codigo"
}
trap limpiar EXIT
trap 'error "interrumpido por el usuario"; exit 130' INT TERM
trap 'error "fallo en la línea ${BASH_LINENO[0]}: ${BASH_COMMAND}"' ERR
# getopts ":hvnd:" como en 04-03: -v pone TRAMONTANA_VERBOSE=1, -n DRY_RUN=1,
# -d fija DESTINO, y cualquier otra cosa acaba en 'morir 2'.
requiere_comando tar gzip sha256sum || morir 69 "faltan dependencias"
mkdir -p "$DESTINO" || morir 73 "no puedo crear $DESTINO"   # idempotente

# El bloqueo va antes de tocar nada: si hay otra copia, salimos sin ensuciar.
exec 9>"$BLOQUEO" || morir 73 "no puedo abrir $BLOQUEO"
flock -n 9 || morir 75 "ya hay otra copia en marcha; abandono"
release=$(basename "$(readlink -f "$ENLACE_APP")")
fecha=$(date +%F)
archivo="${DESTINO}/tramontana-${fecha}.tar.gz"
log "release activo: $release, destino: $archivo"
if (( DRY_RUN )); then
    printf 'Se copiarían %s y el release %s -> %s\n' \
        "${ORIGENES[*]}" "$release" "$archivo"
    exit 0
fi
TEMPORAL=$(mktemp -d "${DESTINO}/.respaldo.XXXXXXXX") || morir 73 "sin temporal"
for origen in "${ORIGENES[@]}"; do
    [[ -r $origen ]] || morir 66 "no puedo leer $origen"
    cp -a -- "$origen" "$TEMPORAL/"
    log "copiado $origen ($(formatear_bytes "$(stat -c %s "$origen")"))"
done
cp -a -- "/opt/tramontana/releases/${release}" "${TEMPORAL}/release-${release}"
printf 'release=%s\nfecha=%s\nhost=%s\n' "$release" "$fecha" "$(hostname)" \
    > "${TEMPORAL}/MANIFIESTO"
# Se escribe como .parcial y se renombra al final: el fichero definitivo solo
# aparece si tar terminó bien, y mv dentro del mismo sistema de ficheros es
# atómico, igual que el 'ln -sfn' del despliegue.
tar -czf "${archivo}.parcial" -C "$TEMPORAL" .
mv -- "${archivo}.parcial" "$archivo"
# app.conf contiene db_password: 640 y grupo tramontana, nunca legible por
# otros. Es el fallo exacto que obligó a rotar la contraseña en julio.
sha256sum "$archivo" > "${archivo}.sha256"
chmod 640 "$archivo" "${archivo}.sha256"
tar -tzf "$archivo" >/dev/null || morir 1 "el archivo generado no es legible"
log "copia verificada: $(formatear_bytes "$(stat -c %s "$archivo")")"
printf '%s\n' "$archivo"          # stdout: la ruta, para encadenar
exit 0
operador@srv-tramontana:~$ ~/scripts/respaldo_tramontana.sh -v
[2026-08-18 13:20:04] release activo: 3.2.1, destino: .../tramontana-2026-08-18.tar.gz
[2026-08-18 13:20:04] copiado /home/operador/datos/reservas.csv (1.8 KiB)
[2026-08-18 13:20:04] copiado /etc/tramontana/app.conf (412 B)
[2026-08-18 13:20:11] copia verificada: 31.4 MiB
/srv/tramontana/backups/envios/tramontana-2026-08-18.tar.gz
operador@srv-tramontana:~$ ~/scripts/respaldo_tramontana.sh &   # y a la vez:
operador@srv-tramontana:~$ ~/scripts/respaldo_tramontana.sh; echo "código: $?"
[2026-08-18 13:20:14] ERROR: ya hay otra copia en marcha; abandono
código: 75

Prueba también el camino de fallo, que es donde el script demuestra su valor: interrúmpelo con Ctrl+C a mitad y comprueba que no queda ningún .respaldo.XXXXXXXX ni ningún .parcial en el destino. La limpieza que no has probado es limpieza que no funciona.

Errores Comunes y Consejos

  • Creer que set -e lo detiene todo. No actúa en condiciones, ni tras &&/||, ni dentro de funciones usadas como condición, ni en local x=$(cmd): sigue comprobando lo importante a mano.
  • No capturar $? en la primera línea del manejador EXIT. Cualquier comando anterior lo sobrescribe y el script devuelve 0 aunque haya fallado.
  • Usar comillas dobles en el manejador de trap. Las variables se expanden al instalar la trampa, no al dispararla: comillas simples salvo que sepas por qué. Y no borres el fichero de bloqueo al terminar, porque abre una carrera entre procesos: el bloqueo lo libera el kernel al morir el proceso.
  • mktemp sin trap ... EXIT. Cada ejecución fallida deja basura: van siempre juntos. Evita también los nombres predecibles en /tmp, porque $$ no es aleatorio: es un ataque de enlaces simbólicos esperando ocurrir.
  • Scripts no idempotentes. Si relanzarlo duplica una línea o suma dos veces, no puedes reintentar tras un fallo, que es justo cuando más falta hace.
  • Consejo: escribe el fichero definitivo con un nombre temporal y renómbralo al final; es la forma barata de que nunca exista un fichero a medias. Y cuando algo no funcione, bash -x con PS4 antes de releer el código: cinco segundos de traza ahorran veinte minutos de suposiciones.
  • Consejo: prueba el camino de error, no solo el de éxito: quita permisos, llena el disco, mata el proceso a mitad. Es la única forma de saber si tus trampas funcionan.

Ejercicios

Ejercicio 1. Este script parece correcto y falla en silencio. Explica por qué y arréglalo de dos formas.

#!/usr/bin/env bash
set -euo pipefail
preparar_destino() {
    mkdir -p /srv/tramontana/backups/nuevo
    cp /etc/tramontana/no_existe.conf /srv/tramontana/backups/nuevo/
    echo "destino preparado"
}
if preparar_destino; then
    echo "empezando la copia..."
fi

Ejercicio 2. Añade a lib/comunes.sh una función con_limpieza: debe crear un directorio temporal, publicarlo en TRAMONTANA_TMP, instalar el trap de limpieza y devolver 0. Escribe un caso de prueba que verifique que, tras un script que muere por set -e, el directorio ya no existe.

Ejercicio 3. Haz idempotente este fragmento de despliegue de Luis y explica qué problema resuelve cada cambio: mkdir /opt/tramontana/releases/3.3.0, tar -xzf /tmp/app-3.3.0.tar.gz -C /opt/tramontana/releases/3.3.0, ln -s /opt/tramontana/releases/3.3.0 /opt/tramontana/app y echo "3.3.0" >> /opt/tramontana/HISTORIAL.

Soluciones

Solución 1. El fallo es el caso 4 del apartado 4: preparar_destino se usa como condición de un if, y eso desactiva set -e en toda la función. El cp falla, imprime su error, la ejecución continúa, se imprime «destino preparado», la función devuelve 0 —el código del echo— y el if da por bueno el resultado. El script anuncia que empieza la copia con el destino incompleto.

# Arreglo A: la función comprueba y sale explícitamente.
preparar_destino() {
    mkdir -p /srv/tramontana/backups/nuevo || return 1
    cp /etc/tramontana/no_existe.conf /srv/tramontana/backups/nuevo/ || return 1
    echo "destino preparado"
}
# Arreglo B: no usarla como condición; que set -e haga su trabajo.
preparar_destino; echo "empezando la copia..."
operador@srv-tramontana:~$ bash /tmp/prep_a.sh; echo "código: $?"
cp: no se puede efectuar `stat' sobre '/etc/tramontana/no_existe.conf': ...
código: 1

El arreglo A es preferible cuando la función puede fallar de formas que quieres distinguir; el B, cuando cualquier fallo debe abortar. Lo que no vale es el original, que promete comprobar y no comprueba.

Solución 2.

# con_limpieza [PLANTILLA] -> crea un temporal, lo publica en TRAMONTANA_TMP
#   e instala el trap EXIT que lo borra. Devuelve: 0 si se creó; 73 si no.
con_limpieza() {
    TRAMONTANA_TMP=$(mktemp -d "${1:-/tmp/tramontana.XXXXXXXX}") || return 73
    # Comillas SIMPLES: la variable se lee al dispararse el trap, no ahora.
    trap 'rm -rf -- "${TRAMONTANA_TMP:-}"' EXIT
    return 0
}
# Prueba: un script hijo que crea el temporal y muere por set -e.
guardado=$(bash -c '
    set -euo pipefail
    source '"$SCRIPT_DIR"'/lib/comunes.sh
    con_limpieza; printf "%s\n" "$TRAMONTANA_TMP"
    false                      # muere aquí; el trap EXIT debe dispararse
' 2>/dev/null) || true
[[ -d $guardado ]] && resultado="sigue existiendo" || resultado="borrado"
comprobar "temporal limpiado tras fallo" "borrado" "$resultado"
operador@srv-tramontana:~$ ~/scripts/test_comunes.sh | tail -2
  ok    temporal limpiado tras fallo
0 fallo(s)

La prueba importa porque la función depende de dos sutilezas fáciles de romper: las comillas simples del trap y el hecho de que EXIT se dispare también cuando quien mata al script es set -e. Un test lo fija; un comentario no.

Solución 3.

version="3.3.0"; destino="/opt/tramontana/releases/${version}"
mkdir -p "$destino"                      # -p no falla si ya existe
# Comprobar el paquete ANTES de extraer, como manda el curso desde 02-04.
tar -tzf "/tmp/app-${version}.tar.gz" >/dev/null || morir 65 "paquete corrupto"
# Limpiamos antes de extraer para que no queden restos de un intento
# anterior con ficheros que ya no forman parte del release.
rm -rf -- "${destino:?}"/*
tar -xzf "/tmp/app-${version}.tar.gz" -C "$destino"
# ln -sfn: -f reemplaza el enlace existente, -n evita que, al existir ya
# 'app' como enlace a un directorio, el nuevo se cree DENTRO de él. Y
# relativo, como todo el curso: se hace desde /opt/tramontana.
ln -sfn "releases/${version}" /opt/tramontana/app
grep -qxF "$version" /opt/tramontana/HISTORIAL 2>/dev/null \
    || printf '%s\n' "$version" >> /opt/tramontana/HISTORIAL   # solo si falta

Los cuatro cambios. mkdir -p evita que la segunda ejecución muera en la primera línea. El tar -tzf previo detecta un paquete truncado antes de haber tocado nada. ln -sfn arregla el error clásico: ln -s sobre un enlace que ya apunta a un directorio crea el nuevo enlace dentro de ese directorio, dejando /opt/tramontana/releases/3.2.1/3.3.0, y el despliegue no cambia nada mientras tú crees que sí. Y el grep -qxF —-x línea completa, -F texto literal— convierte el >> en condicional, evitando un HISTORIAL con la misma versión repetida cuatro veces.

Conclusión

Tus scripts ya no fallan en silencio, y por primera vez puedes interrumpirlos sin miedo.

  • Depuras con bash -x y un PS4 que dice fichero, línea y función, acotando zonas con set -x/set +x, y compruebas la sintaxis sin ejecutar con bash -n. Entiendes SC2086, SC2046, SC2164 y SC2155, y sabes silenciar un aviso justificándolo en la línea anterior.
  • Conoces el modo estricto pieza a pieza —-e, -u, pipefail— y, sobre todo, los cinco contextos donde set -e no actúa, con el caso de la función usada como condición demostrado y arreglado de dos formas. Usas trap con EXIT, ERR e INT, capturando $? en la primera línea del manejador, con comillas simples, sabiendo que EXIT se dispara pase lo que pase salvo con SIGKILL.
  • Escribes un traza_error() que aprovecha BASH_COMMAND, BASH_LINENO y FUNCNAME para dar una traza de verdad, y diseñas para la idempotencia: mkdir -p, ln -sfn, comprobar antes de añadir, escribir a .parcial y renombrar al final.
  • Reintentas con retroceso exponencial, creas temporales con mktemp en vez de nombres predecibles con $$, y bloqueas con flock sobre un descriptor dentro del propio script.
  • Y respaldo_tramontana.sh existe por fin: con bloqueo, temporales que se limpian solos, escritura atómica, suma de verificación, permisos 640 sobre un archivo que contiene db_password y códigos de salida documentados.

Queda una última pregunta, la que separa un script que funciona de uno que se despliega: ¿qué pasa cuando esto lo ejecute cron a las 4:20 sin nadie delante? ¿Dónde va el registro? ¿Cómo se avisa a Marta solo si hay algo que hacer? ¿De dónde sale la contraseña de la base de datos si no puede estar en el fichero? ¿Y qué ocurre si una ejecución tarda más que el intervalo entre ejecuciones? En Scripts de Producción: Buenas Prácticas cerramos el módulo con esa checklist completa, la plantilla canónica con main "$@", la precedencia de configuración implementada de verdad, el principio de «silencio si todo va bien», las pruebas antes de producción, y el proyecto integrador: desplegar.sh, con validación, copia previa, cambio atómico del enlace, comprobación por curl y rollback automático si el 8080 no responde.

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