Tus scripts ya funcionan. Pero «funcionar» es lo que hacen a mediodía, con tu terminal abierta, tus variables cargadas y tú mirando la salida. Producción es otra cosa: son las 4:20 de la madrugada, no hay nadie delante, el PATH es el pobre de cron, el disco está más lleno que ayer, la red va rara y la única prueba de lo que ocurrió será lo que tu script haya dejado escrito. Esta lección cierra el módulo con la checklist que salva esa distancia y con el proyecto que la demuestra: desplegar.sh, el script de despliegue de Tramontana Reservas, con validación, copia previa, cambio atómico y rollback automático si la aplicación no responde. Es el examen final del módulo, y usa absolutamente todo lo aprendido.
Contenido
- La checklist de producción
- Estructura canónica y
main "$@" - Configuración: la precedencia, implementada
- Secretos
- Registro: qué, cómo y adónde
- Salida para humanos y para máquinas
- Bloqueo, tiempos límite y solapamiento
- Notificaciones: silencio si todo va bien
- Pruebas, versionado y documentación
- Cuándo dejar Bash
- Proyecto:
desplegar.sh
- La checklist de producción
| Requisito | Por qué |
|---|---|
| Rutas absolutas, sin depender del directorio de trabajo | Cron arranca en $HOME con un PATH mínimo |
Modo estricto y trap de limpieza |
Nadie va a recoger los temporales por ti |
Idempotente y con --dry-run |
Para poder relanzarlo tras un fallo sin pensar |
| Bloqueo y tiempo límite | Dos a la vez, o una colgada, hacen más daño que ninguna |
| Registro con fecha, decisiones y duración | Es la única prueba de lo que pasó |
| Códigos de salida documentados | Quien lo llama debe poder actuar sin leer la salida |
| Sin secretos en el código ni en la línea de comandos | ps los enseña a cualquiera |
| Silencio si todo va bien | Una alerta que suena a diario deja de leerse |
shellcheck limpio y probado en un entorno de pruebas |
Un fallo de sintaxis a las 4:20 no lo arregla nadie |
Versionado en git, con --help y un README |
Dentro de un año no recordarás por qué |
Ninguno es opcional en un script que corre solo: los diez juntos son la diferencia entre una automatización y una bomba de relojería.
- Estructura canónica y
main "$@"
main "$@"La plantilla completa, evolución de la de 04-01. Cópiala tal cual:
#!/usr/bin/env bash
#
# nombre.sh - Qué hace, en una línea.
# Autor : Nombre <correo> Fecha: 2026-08-18 Versión: 1.0.0
# Uso : nombre.sh [-h] [-v] [-q] [-n] <argumento>
# Salida: 0 ok | 2 uso incorrecto | 69 servicio no disponible | 75 ya en marcha
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"
# --- Constantes ---------------------------------------------------------
readonly VERSION_SCRIPT="1.0.0"
readonly BLOQUEO="/var/lock/nombre.lock"
UMBRAL=80 # configuración: 1. valor por defecto
# --- Funciones ----------------------------------------------------------
uso() { ... }; validar() { ... }; trabajo() { ... }
# --- Punto de entrada ---------------------------------------------------
main() {
cargar_config # 2. fichero
parsear_opciones "$@" # 4. argumentos (el entorno, 3, va con las constantes)
validar; trabajo
}
main "$@" # <- la ÚLTIMA línea del ficheromain al final permite que el fichero se lea de arriba abajo como un texto: primero las constantes, luego las piezas, y al final el resumen de lo que hace el programa. Como Bash necesita que las funciones estén definidas antes de llamarlas, main "$@" tiene que ser la última línea, y ponerla ahí protege además de un fallo real: si el fichero se trunca al copiarlo, Bash no ejecutará un script a medias, porque la llamada a main habrá desaparecido con el resto. Y va con comillas, siempre, por lo que sabes de 04-03: es la única forma de que los argumentos lleguen intactos.
- Configuración: la precedencia, implementada
La regla de 04-03 —argumentos > entorno > fichero > defectos— se implementa aplicándola en ese orden, de la más débil a la más fuerte:
UMBRAL_DISCO=80 # 1. defecto
cargar_config() { # 2. fichero
local f="${CONFIG:-/etc/tramontana/desplegar.conf}" clave valor
[[ -r $f ]] || return 0
while IFS='=' read -r clave valor; do
clave="${clave// /}"
[[ -z $clave || $clave == \#* ]] && continue
case "$clave" in
umbral_disco) UMBRAL_DISCO="$valor" ;;
url_salud) URL_SALUD="$valor" ;;
*) log "clave desconocida en $f: $clave" ;;
esac
done < "$f"
}
UMBRAL_DISCO="${TRAMONTANA_UMBRAL_DISCO:-$UMBRAL_DISCO}" # 3. entorno
# 4. argumentos: el bucle de getopts, que se ejecuta después${VAR:-$ACTUAL} toma el valor del entorno si existe y, si no, conserva el que ya hubiera: ese pequeño truco es todo el mecanismo. Y registra las claves desconocidas en vez de ignorarlas, porque un umbral_dsico mal escrito que no dice nada es una noche perdida.
- Secretos
Tres reglas, en orden de importancia:
- Nunca en el script. Un fichero versionado en git con una contraseña dentro está comprometido para siempre, aunque la borres: queda en el historial.
- Nunca en la línea de comandos.
mysql -p"$CLAVE"es visible enps auxpara cualquier usuario de la máquina mientras dure el comando. Sí en un fichero con permisos 600, propiedad del usuario del servicio, o en una variable de entorno del gestor de servicios.
leer_secreto() { # leer_secreto RUTA -> imprime el secreto
local f="$1" modo
[[ -r $f ]] || morir 78 "no puedo leer el fichero de secretos: $f"
modo=$(stat -c '%a' "$f") # 600 o 640, nunca legible por otros
[[ $modo == 600 || $modo == 640 ]] || morir 78 "permisos inseguros ($modo)"
< "$f" tr -d '\n'
}Un mínimo digno: nada de contraseñas en el código y verificación activa de los permisos. La gestión seria —pass, un gestor de secretos, certificados TLS— es materia de 06-05.
- Registro: qué, cómo y adónde
Un script de producción registra cinco cosas: que ha empezado (con su versión y sus parámetros), cada decisión que toma, cada cambio que hace en el sistema, que ha terminado, y cuánto ha tardado. Con eso se reconstruye cualquier incidente.
readonly LOG="/var/log/tramontana/despliegue.log"
registrar() { # registrar NIVEL MENSAJE...
local nivel="$1" linea; shift
linea="$(date '+%Y-%m-%dT%H:%M:%S%z') [$nivel] ${0##*/}[$$]: $*"
printf '%s\n' "$linea" >> "$LOG"
(( TRAMONTANA_SILENCIOSO )) || printf '%s\n' "$linea" >&2
}
# Y así queda el log:
2026-08-18T14:02:11+0200 [INFO] desplegar.sh[4471]: inicio v1.0.0, version=3.3.0
2026-08-18T14:02:19+0200 [WARN] desplegar.sh[4471]: salud KO, revirtiendo
2026-08-18T14:02:23+0200 [INFO] desplegar.sh[4471]: fin código=69 duración=12sTres detalles del formato. La fecha ISO 8601 con zona horaria (%Y-%m-%dT%H:%M:%S%z) se ordena alfabéticamente igual que cronológicamente y no es ambigua en el cambio de hora. El PID entre corchetes permite separar dos ejecuciones entrelazadas. Y el mensaje va a la vez al fichero y a stderr, para que sirva igual en cron que a mano.
La duración se mide con SECONDS, la variable que Bash incrementa sola: SECONDS=0 al empezar y $SECONDS al terminar. Y una nota de frontera: en el Módulo 5 sustituiremos este fichero por el journal, con logger -t tramontana -p daemon.info como puente y journalctl para consultarlo (05-06), más logrotate para que el fichero no crezca sin fin.
- Salida para humanos y para máquinas
Un script útil sirve a dos públicos: Marta quiere leer, la monitorización quiere parsear. La solución no es elegir, es ofrecer ambas:
case "$FORMATO" in
texto) LC_ALL=C printf '%-10s %s\n' "Estado:" "$estado" ;;
json) LC_ALL=C printf '{"estado":"%s","version":"%s","duracion":%d}\n' \
"$estado" "$version" "$SECONDS" ;;
esacCon --quiet se suprime todo lo informativo y solo quedan los errores —imprescindible para cron, que envía por correo cualquier salida— y con --json la salida es una sola línea parseable. Si tu JSON empieza a tener estructuras anidadas, esa es la señal del apartado 10: en Bash no hay forma decente de escapar comillas dentro de valores arbitrarios.
- Bloqueo, tiempos límite y solapamiento
Ya tienes el flock sobre el descriptor 9 de 04-06. Falta la otra mitad: qué pasa si una ejecución tarda más que el intervalo entre ejecuciones. La copia nocturna se lanza a las 04:20; si un día tarda 25 horas, la de mañana encontrará el bloqueo. Tres respuestas, en orden de preferencia:
- Abandonar con un código propio (
75) y un aviso. Es lo correcto para copias e informes: la de mañana ya lo hará. - Esperar con
flock -w 300 9, si el solapamiento es breve y esperar es aceptable. Y nunca matar la anterior desde la nueva: si hace falta eso, el problema es el intervalo, no el bloqueo.
A la ejecución hay que ponerle techo, porque un proceso colgado no falla: se queda ahí, ocupando el bloqueo para siempre.
# 20 minutos de límite; -k 30 manda SIGKILL si a los 30 s no ha muerto.
timeout -k 30 20m tar -czf "$archivo" -C "$TEMPORAL" . \
|| morir 73 "la compresión superó el tiempo límite"timeout devuelve 124 cuando corta por tiempo, lo que permite distinguirlo de un fallo normal. Es la aplicación directa de la regla SIGTERM → esperar → SIGKILL de 03-06.
- Notificaciones: silencio si todo va bien
El principio es corto y se incumple siempre: un script que avisa cuando todo va bien enseña a la gente a ignorar sus avisos. Si Marta recibe cada mañana un correo de «copia correcta», a la tercera semana lo archiva sin leer, y el día que ponga «copia FALLIDA» tampoco lo leerá. La regla operativa: notifica solo cuando haya algo que hacer, y que el mensaje diga qué ha pasado, qué impacto tiene y qué se espera de quien lo lee.
notificar() { # notificar ASUNTO CUERPO
(( NOTIFICAR )) || return 0
printf '%s\n\nServidor: %s\nRegistro: %s\n' "$2" "$(hostname)" "$LOG" \
| mail -s "[Tramontana] $1" "$DESTINATARIO"
}
esperar_salud || { notificar "Despliegue revertido" \
"La versión ${version} no respondió en el 8080; se restauró ${previo}."; }Para que el silencio no se confunda con «el script no se ejecutó», la contrapartida es una marca de éxito: la última línea del log con la fecha de hoy, o un fichero ULTIMO_EXITO con la marca de tiempo. Comprobar «¿la marca es de hoy?» es trivial y detecta el 90 % de los problemas, incluido el peor: que la tarea haya dejado de ejecutarse.
- Pruebas, versionado y documentación
Antes de que un script toque producción:
shellchecksin avisos ybash -n, ambos obligatorios en CI:find scripts -name '*.sh' -exec shellcheck {} +.--dry-runcompleto contra el sistema real, leyendo la salida entera: es la última oportunidad de ver lo que va a pasar. Y en un entorno de pruebas con los mismos datos y rutas, con snapshot de la VM antes de cada prueba destructiva.- El camino de fallo, no solo el de éxito: quita permisos al destino, apunta la URL de salud a un puerto cerrado, interrumpe con Ctrl+C a mitad. Si solo lo has probado cuando todo va bien, no lo has probado.
- Con el entorno de cron, que es distinto del tuyo:
env -i /home/operador/scripts/desplegar.sh -n 3.3.0 /tmp/app.tgzreproduce la pobreza de variables que tendrá a las 4:20.
El versionado no es opcional: ~/scripts es un repositorio git, con un commit por cambio y un mensaje que diga por qué. Desplegar el script es entonces git pull en el servidor —o mejor, copiarlo con rsync desde un repositorio central, sin dejar credenciales de git en el servidor—. Y la documentación mínima son tres cosas: la cabecera con uso y códigos de salida, un --help que funcione siempre, y un README.md en la carpeta que diga qué script hace qué y cuál llama a cuál. Nada más; nada menos.
- Cuándo dejar Bash
Bash es excelente para orquestar comandos. Deja de serlo cuando aparece cualquiera de estas señales:
| Señal | Hacia dónde |
|---|---|
Más de ~300 líneas, o main que no cabe en una pantalla |
Python |
| Estructuras de datos anidadas, JSON más allá de leer un campo | Python (json), o jq si es solo consulta |
| Cálculo numérico, fechas complicadas, informes | Python |
| Concurrencia real con coordinación entre tareas | Python, Go |
| Configurar máquinas, y más de una | Ansible (07-06) |
| Dependencias entre pasos, estado entre ejecuciones | Una herramienta de orquestación |
La señal más honesta: si estás escribiendo eval, o escapando comillas dentro de comillas dentro de comillas, ya has pasado el límite. Para Tramontana la frontera natural es Ansible: en el momento en que el despliegue tenga que hacerse en tres servidores a la vez, desplegar.sh se convierte en un rol de Ansible, y este módulo habrá cumplido su función, que era enseñarte exactamente qué hace ese rol por debajo.
- Proyecto:
desplegar.sh
desplegar.shEl examen final del módulo. Valida, comprueba precondiciones, copia antes de tocar, extrae, verifica, cambia el enlace de forma atómica, comprueba con curl y revierte solo si la aplicación no responde.
#!/usr/bin/env bash
#
# desplegar.sh - Despliegue de Tramontana Reservas con rollback automático.
# Autor : Operador de sistemas <operador@srv-tramontana> Fecha: 2026-08-18
# Uso : desplegar.sh [-h] [-v] [-q] [-n] [-t SEG] <version> <paquete.tar.gz>
# Salida: 0 ok | 2 uso | 65 paquete inválido | 66 origen ilegible | 73 no se
# puede escribir | 69 la app no respondió (revertido) | 75 ya en marcha
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"
readonly VERSION_SCRIPT="1.0.0"
readonly BASE="/opt/tramontana"; readonly RELEASES="${BASE}/releases"
readonly BLOQUEO="/var/lock/tramontana-despliegue.lock"
readonly LOG="/var/log/tramontana/despliegue.log"
URL_SALUD="${TRAMONTANA_URL_SALUD:-http://10.0.2.15:8080/salud}"
ESPERA=60; DRY_RUN=0
version=""; paquete=""; previo=""; enlace_cambiado=0
# registrar() y uso() son las del apartado 5 y de 04-03, sin cambios.
revertir() { # deshace el cambio de enlace si se hizo
(( enlace_cambiado )) || return 0
registrar WARN "revirtiendo al release $previo"
( cd "$BASE" && ln -sfn "releases/${previo}" app )
enlace_cambiado=0
}
limpiar() {
local codigo=$?
(( codigo != 0 )) && revertir
registrar INFO "fin código=${codigo} duración=${SECONDS}s"
exit "$codigo"
}
trap limpiar EXIT
trap 'registrar ERROR "interrumpido por señal"; exit 130' INT TERM
validar() {
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || morir 2 "versión inválida: $version"
[[ -r $paquete ]] || morir 66 "no puedo leer el paquete: $paquete"
# tar -tzf antes de extraer: convención del curso desde 02-04.
tar -tzf "$paquete" >/dev/null 2>&1 || morir 65 "el paquete no es un tar.gz válido"
[[ -w $RELEASES ]] || morir 73 "no puedo escribir en $RELEASES"
requiere_comando tar curl timeout || morir 69 "faltan dependencias"
}
esperar_salud() { # 0 si responde 200 antes de $ESPERA segundos
local fin=$(( SECONDS + ESPERA )) codigo
while (( SECONDS < fin )); do
codigo=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL_SALUD" || true)
[[ $codigo == 200 ]] && return 0
sleep 3
done
return 1
}
main() {
SECONDS=0
while getopts ":hvqnt:" o; do
case "$o" in
h) uso; exit 0 ;; n) DRY_RUN=1 ;; t) ESPERA="$OPTARG" ;;
v) TRAMONTANA_VERBOSE=1 ;; q) TRAMONTANA_VERBOSE=0 ;;
\?) uso >&2; morir 2 "opción desconocida: -$OPTARG" ;;
:) morir 2 "la opción -$OPTARG necesita un valor" ;;
esac
done
shift $(( OPTIND - 1 ))
[[ $# -eq 2 ]] || { uso >&2; morir 2 "hacen falta <version> y <paquete>"; }
version="$1"; paquete="$2"; validar
exec 9>"$BLOQUEO" || morir 73 "no puedo abrir $BLOQUEO"
flock -n 9 || morir 75 "ya hay otro despliegue en marcha"
previo=$(basename "$(readlink -f "${BASE}/app")")
registrar INFO "inicio v${VERSION_SCRIPT}, version=${version}, previo=${previo}"
[[ $version == "$previo" ]] && { registrar INFO "ya desplegada; nada que hacer"; exit 0; }
if (( DRY_RUN )); then
registrar INFO "SIMULACIÓN: extraería $paquete en ${RELEASES}/${version}"
registrar INFO "SIMULACIÓN: app -> releases/${version}, salud en $URL_SALUD"
exit 0
fi
"${SCRIPT_DIR}/respaldo_tramontana.sh" -q >/dev/null \
|| morir 73 "la copia previa falló; abandono sin tocar nada"
registrar INFO "copia previa completada"
mkdir -p "${RELEASES}/${version}" # idempotente
rm -rf -- "${RELEASES:?}/${version:?}"/*
timeout -k 30 10m tar -xzf "$paquete" -C "${RELEASES}/${version}"
[[ -s "${RELEASES}/${version}/app.jar" ]] || morir 65 "el release no trae app.jar"
registrar INFO "release ${version} extraído ($(formatear_bytes \
"$(du -sb "${RELEASES}/${version}" | cut -f1)"))"
# Cambio atómico: ln -sfn con destino relativo, como manda el Módulo 2.
( cd "$BASE" && ln -sfn "releases/${version}" app )
enlace_cambiado=1
registrar INFO "enlace app -> releases/${version}"
if esperar_salud; then
enlace_cambiado=0 # confirmado: no hay que revertir
registrar INFO "salud OK en ${URL_SALUD}"
printf '%s\n' "$version" # stdout: la versión activa
exit 0
fi
registrar ERROR "la versión ${version} no respondió en ${ESPERA}s"
exit 69 # el trap EXIT hará el rollback
}
main "$@"operador@srv-tramontana:~$ ~/scripts/desplegar.sh 3.3.0 /tmp/app-3.3.0.tar.gz
2026-08-18T14:02:11+0200 [INFO] desplegar.sh[4471]: inicio v1.0.0, version=3.3.0, previo=3.2.1
2026-08-18T14:02:14+0200 [INFO] desplegar.sh[4471]: copia previa completada
2026-08-18T14:02:16+0200 [INFO] desplegar.sh[4471]: release 3.3.0 extraído (99.2 MiB)
2026-08-18T14:02:16+0200 [INFO] desplegar.sh[4471]: enlace app -> releases/3.3.0
2026-08-18T14:03:16+0200 [ERROR] desplegar.sh[4471]: la versión 3.3.0 no respondió en 60s
2026-08-18T14:03:16+0200 [WARN] desplegar.sh[4471]: revirtiendo al release 3.2.1
2026-08-18T14:03:16+0200 [INFO] desplegar.sh[4471]: fin código=69 duración=65s
operador@srv-tramontana:~$ readlink /opt/tramontana/app
releases/3.2.1El servicio siguió funcionando. Eso es lo que este módulo ha construido: la 3.3.0 no arrancó, el script lo detectó, revirtió al enlace anterior en el mismo segundo, lo registró todo y devolvió un código (69) que dice exactamente qué pasó. Y con -n habrías visto antes lo que iba a intentar, sin tocar nada.
Repasa dónde vive cada lección: el shebang y el exit explícito son de 04-01; ${VAR:-}, los arrays y printf de 04-02; getopts, uso(), stdout frente a stderr y los códigos de salida de 04-03; [[ =~ ]], case y el while de espera de 04-04; SCRIPT_DIR, la librería y morir() de 04-05; y set -euo pipefail, los trap, el bloqueo, la idempotencia y timeout de 04-06. Nada aquí es nuevo: es todo lo anterior, junto.
Errores Comunes y Consejos
- Rutas relativas. Funcionan desde tu terminal y fallan desde cron: absolutas siempre, o derivadas de
SCRIPT_DIR. Y no supongas el PATH, que en cron es/usr/bin:/bin; si usas algo de/usr/local/bin, ruta completa oPATH=explícito al principio. - Poner
main "$@"sin comillas o en medio del fichero. Lo primero rompe los argumentos con espacios; lo segundo falla porque las funciones aún no están definidas. Y no notifiques el éxito: enseña a ignorar las notificaciones. Silencio si todo va bien, y una marca de éxito comprobable. - Registrar solo el final. Sin las decisiones intermedias no puedes reconstruir nada; registra también lo que decidiste no hacer. Y pon siempre tiempo límite: un proceso colgado no falla, se queda ahí con el bloqueo cogido hasta que alguien lo mata.
- Desplegar sin
--dry-runprevio. Treinta segundos de lectura contra media hora de restauración. - Consejo: guarda
~/scriptsen git desde hoy aunque solo seas tú, porquegit logresponde a «¿desde cuándo hace esto?» mejor que tu memoria. Y haz que todo script imprima su versión en la primera línea del log: cuando dos servidores se comporten distinto, será lo primero que compares. - Consejo: el mejor momento para escribir el
READMEde la carpeta es hoy, cuando aún recuerdas por qué existe cada script.
Ejercicios
Ejercicio 1. Añade a desplegar.sh una opción --json que, al terminar, imprima por stdout una línea con version, previo, estado (desplegado o revertido), codigo y duracion. Debe funcionar también cuando hay rollback, sin duplicar código.
Ejercicio 2. Escribe ~/scripts/comprobar_tareas.sh, que verifique que las tres tareas nocturnas (cron-copia.log, cron-purga.log y el despliegue) han dejado marca de hoy y notifique solo si falta alguna, aplicando el principio de silencio si todo va bien.
Ejercicio 3. Luis quiere meter esta línea en /etc/cron.d/tramontana: 20 4 * * * operador cd ~/scripts && ./desplegar.sh $VERSION /tmp/app.tar.gz. Encuentra los cinco problemas y reescríbela.
Soluciones
Solución 1. La clave es no duplicar: el punto único de salida ya existe —el trap limpiar EXIT— y es ahí donde debe imprimirse el resumen.
FORMATO="texto"; estado="desconocido"
# En el bucle de opciones, que pasa a manual por ser una opción larga:
# --json) FORMATO="json"; shift ;;
resumen() { # resumen CODIGO
(( $1 == 0 )) && estado="desplegado" || estado="revertido"
case "$FORMATO" in
json) LC_ALL=C printf \
'{"version":"%s","previo":"%s","estado":"%s","codigo":%d,"duracion":%d}\n' \
"$version" "$previo" "$estado" "$1" "$SECONDS" ;;
*) printf '%s\n' "$version" ;;
esac
}
# En limpiar(), justo antes del exit: resumen "$codigo"
operador@srv-tramontana:~$ ~/scripts/desplegar.sh --json 3.3.0 /tmp/app.tgz 2>/dev/null
{"version":"3.3.0","previo":"3.2.1","estado":"revertido","codigo":69,"duracion":65}El 2>/dev/null deja solo la línea JSON, porque el registro va a stderr: la separación de canales de 04-03 dando su fruto. Y como el exit 0 del camino de éxito también dispara el EXIT, se elimina el printf '%s\n' "$version" del cuerpo de main para no imprimirlo dos veces.
Solución 2.
#!/usr/bin/env bash
# comprobar_tareas.sh - Verifica que las tareas nocturnas dejaron marca de hoy.
# Autor : Operador de sistemas <operador@srv-tramontana> Fecha: 2026-08-18
# Uso : comprobar_tareas.sh [-v] Salida: 0 todo al día | 1 falta alguna
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/comunes.sh" # shellcheck source=lib/comunes.sh
readonly DESTINATARIO="[email protected]"
declare -A TAREAS=( [copia]="/var/log/tramontana/cron-copia.log"
[purga]="/var/log/tramontana/cron-purga.log"
[despliegue]="/var/log/tramontana/despliegue.log" )
hoy=$(date '+%Y-%m-%d'); faltan=()
for tarea in "${!TAREAS[@]}"; do
fichero="${TAREAS[$tarea]}"
# -s: existe y no está vacío. tail -1 evita leer un log de miles de líneas.
if [[ -s $fichero ]] && [[ $(tail -1 "$fichero") == "$hoy"* ]]; then
log "$tarea: al día"
else faltan+=("$tarea"); fi
done
(( ${#faltan[@]} == 0 )) && exit 0 # silencio si todo va bien
printf 'Sin marca de hoy (%s): %s\n' "$hoy" "${faltan[*]}" >&2
printf 'Las tareas %s no han dejado registro de hoy en %s.\nRevisa cron y el disco.\n' \
"${faltan[*]}" "$(hostname)" | mail -s "[Tramontana] Tareas sin ejecutar" "$DESTINATARIO"
exit 1
operador@srv-tramontana:~$ ~/scripts/comprobar_tareas.sh; echo "código: $?"
código: 0
operador@srv-tramontana:~$ ~/scripts/comprobar_tareas.sh -v; echo "código: $?"
[2026-08-18 08:05:01] copia: al día
[2026-08-18 08:05:01] purga: al día
Sin marca de hoy (2026-08-18): despliegue
código: 1Sin -v y con todo correcto el script no imprime nada y devuelve 0: cron no manda correo y Marta no recibe ruido. El correo solo se envía en el camino de fallo, diciendo qué pasa y qué revisar, como pide la convención del curso.
Solución 3. Los cinco problemas:
~no se expande en un crontab de sistema. El campo se pasa ash -ccon un entorno mínimo yHOMEpuede no estar definido: rutas absolutas.$VERSIONestá vacía. Cron no hereda tu entorno, así que el script recibiría un argumento donde esperaba dos y la validación lo rechazaría. Menos mal.- Falta redirigir la salida. Sin
>> log 2>&1, cualquier mensaje se convierte en un correo y cualquier error se pierde si el correo no está configurado. - Sin
SHELLniPATHdeclarados al principio del fichero, y con la hora mal elegida: las 04:20 evitan la franja del cambio horario y el solape con otras tareas. - Desplegar automáticamente por cron es una mala idea de fondo. Un despliegue lo aprueba Marta; lo que debe correr solo es la copia.
# /etc/cron.d/tramontana
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
# Copia diaria. 04:20 para no solaparse con la rotación de logs.
20 4 * * * operador /home/operador/scripts/respaldo_tramontana.sh -q \
>> /var/log/tramontana/cron-copia.log 2>&1
# Comprobación de que las tareas nocturnas dejaron marca. Solo avisa si falla.
5 8 * * * operador /home/operador/scripts/comprobar_tareas.sh \
>> /var/log/tramontana/cron-copia.log 2>&1Rutas absolutas, PATH explícito, MAILTO="" porque el script ya escribe su propio log y solo notifica cuando hace falta, y el despliegue fuera de cron: se lanza a mano cuando Marta lo aprueba. En el Módulo 5 veremos que un timer de systemd hace todo esto con mejor registro y mejor control de solapamiento.
Conclusión
Has cerrado el módulo, y con él la distancia entre «me funciona» y «corre solo».
- Tienes una checklist de producción de diez puntos y una plantilla canónica con
main "$@"como última línea, que hace que el script se lea de arriba abajo y no se ejecute a medias si se trunca. Implementas la precedencia de configuración —argumentos > entorno > fichero > defectos— de verdad, registrando incluso las claves desconocidas. - Mantienes los secretos fuera del código y de la línea de comandos, y verificas los permisos del fichero que los guarda.
- Registras inicio, decisiones, cambios, fin y duración, con fecha ISO y PID, a fichero y a stderr, sabiendo que en 05-06 eso pasará al journal.
- Produces salida para humanos y para máquinas, con
--quiety--json. Controlas el solapamiento conflock, pones techo contimeoutsabiendo que 124 significa «se agotó el tiempo», y aplicas el silencio si todo va bien con una marca de éxito comprobable como contrapartida. - Pruebas con
shellcheck,bash -n,--dry-run, entorno de pruebas,env -iy el camino de fallo; versionas en git y documentas con cabecera,--helpyREADME. - Reconoces cuándo dejar Bash —300 líneas, JSON anidado, concurrencia,
eval— y hacia dónde ir. Ydesplegar.shexiste: valida, bloquea, copia antes de tocar, extrae con tiempo límite, cambia el enlace de forma atómica, comprueba la salud y revierte solo cuando la 3.3.0 no arranca, dejando el servicio en pie.
En /home/operador/scripts/ hay ahora una caja de herramientas real: revision_salud.sh, informe_reservas.sh, resumen_reservas.sh, purgar_releases.sh, respaldo_tramontana.sh, comprobar_tareas.sh, desplegar.sh, la librería lib/comunes.sh y su test_comunes.sh, todos con la misma estructura, los mismos códigos de salida y la misma disciplina de errores. Eso ya no es «saber Bash»: es saber automatizar.
Pero fíjate en lo que ha ido apareciendo entre líneas y hemos ido aplazando. respaldo_tramontana.sh escribe en /var/log/tramontana/ y nadie rota ese fichero. desplegar.sh cambia el enlace pero no reinicia la aplicación, porque aún no sabes gestionar servicios. La copia va a /srv/tramontana/backups sin que hayamos hablado de cuánto disco hay ni de qué pasa cuando se acabe. El grupo tramontana sigue sin crearse formalmente, svc-tramontana existe por arte de magia, y sudo lo has usado sin preguntarte quién decide qué puedes hacer con él. En el Módulo 5: Administración del Sistema todo eso deja de ser magia: crearás usuarios y grupos de verdad, configurarás sudo y los permisos especiales, instalarás y fijarás versiones de paquetes, gestionarás discos y sistemas de ficheros, convertirás tus scripts en servicios y timers de systemd con arranque, dependencias y reinicio automático, mandarás tus registros al journal con rotación, medirás el rendimiento del servidor, y cerrarás con una estrategia de respaldo y restauración digna de ese nombre. Los scripts que has escrito serán las piezas; el Módulo 5 es el sistema que las sostiene. Actualiza el snapshot de tu VM y nos vemos allí.
Curso de Linux: De Principiante a Administrador de Sistemas
Módulo 1: Introducción a Linux
- ¿Qué es Linux?
- Historia de Linux
- Distribuciones de Linux
- Instalando Linux
- Primer Contacto con el Sistema
- Estructura del Sistema de Archivos de Linux
Módulo 2: Comandos Básicos de Linux
- Introducción a la Línea de Comandos
- Obtener Ayuda y Documentación del Sistema
- Navegando el Sistema de Archivos
- Operaciones con Archivos y Directorios
- Visualización y Edición de Archivos
- Enlaces Duros y Simbólicos
- Permisos y Propiedad de Archivos
Módulo 3: Habilidades Avanzadas en la Línea de Comandos
- El Entorno del Shell: Variables, Alias e Historial
- Uso de Comodines y Expresiones Regulares
- Búsqueda de Archivos y Contenido: find, locate y grep
- Tuberías y Redirección
- Procesamiento de Texto: cut, sort, uniq, sed y awk
- Gestión de Procesos
- Programación de Tareas con Cron
- Comandos de Redes
Módulo 4: Scripting en Shell
- Introducción al Scripting en Shell
- Variables y Tipos de Datos
- Entrada, Salida y Argumentos de un Script
- Estructuras de Control
- Funciones y Librerías
- Depuración y Manejo de Errores
- Scripts de Producción: Buenas Prácticas
Módulo 5: Administración del Sistema
- Gestión de Usuarios y Grupos
- sudo y Permisos Especiales
- Gestión de Paquetes
- Gestión de Discos
- systemd y la Gestión de Servicios
- Registros del Sistema: journald y syslog
- Monitoreo del Sistema y Optimización del Rendimiento
- Respaldo y Restauración
Módulo 6: Redes y Seguridad
- Configuración de Redes
- SSH y Acceso Remoto
- Firewall y Seguridad Perimetral
- Sistemas de Detección de Intrusos
- Gestión de Secretos y Certificados TLS
- Asegurando Sistemas Linux
Módulo 7: Temas Avanzados
- El Proceso de Arranque y la Recuperación del Sistema
- Diagnóstico Avanzado: strace, perf y eBPF
- Optimización del Kernel de Linux
- Virtualización con Linux
- Contenedores de Linux y Docker
- Automatización con Ansible
- Alta Disponibilidad y Balanceo de Carga
