Los scripts de shell robustos resultan aburridos. Esa es la idea. Un script que puedes leer a las 3 de la madrugada durante un incidente, que falla con ruido en lugar de corromper el estado, supera siempre a una línea ingeniosa.
Las tres primeras líneas que importan
La mayoría de los scripts de shell rotos tienen un rasgo en común: siguen adelante después de que algo ya ha fallado. El comportamiento por defecto es encogerse de hombros ante un error y continuar, que es como un script de copia de seguridad «tiene éxito» escribiendo cero bytes.
Empieza todos los scripts de la misma manera:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'Esto es lo que consigues con eso:
set -eaborta con el primer comando que devuelve un valor distinto de cero, así que un fallo se detiene en lugar de propagarse en daños.set -utrata las variables sin definir como errores. Te equivocas al escribir el nombre de una variable y te enteras enseguida, en lugar de querm -rf "$PREFX/data"se expanda arm -rf "/data".set -o pipefailhace que una tubería falle si falla cualquier etapa, no solo la última. Sin él,curl ... | tar xinforma de éxito incluso cuandocurlmurió.- Fijar
IFSen salto de línea y tabulador elimina toda una clase de errores de división de palabras con nombres de archivo que contienen espacios.
Esto no es paranoia. Es la diferencia entre un script que se detiene y un script que causa daños a gran escala.
Pon comillas a todo
La división de palabras y la expansión de patrones son las dos trampas que arruinan los scripts de shell. Una variable sin comillas se divide por los espacios y se expande como un patrón, lo que significa que un archivo llamado my report.txt llega como dos argumentos, y una variable que contiene * coincide con todo tu directorio.
La regla es simple: si es una variable o una sustitución de comandos, va entre comillas dobles.
# mal: se rompe con los espacios, expande los globs
cp $src $dst
# bien
cp "$src" "$dst"Usa "$@" (entre comillas) para reenviar argumentos, nunca $* ni un $@ pelado. Usa un array para una lista de elementos en lugar de una cadena delimitada por espacios:
files=("$dir"/*.log)
gzip "${files[@]}"Si te sorprendes construyendo un comando en una variable de cadena y ejecutándolo coneval, detente. Casi seguro que lo que quieres es un array.evales donde las comillas van a morir.
Funciones, no scripts planos
Un script de 300 líneas que se ejecuta de arriba abajo es un script que nadie puede probar. Divide el trabajo en funciones, ponles nombre por lo que hacen y deja un main al final. Declara las variables locales con local para que una variable dentro de una función no pueda pisar en silencio a otra.
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
upload_artifact() {
local file="$1" bucket="$2"
[[ -f "$file" ]] || die "missing artifact: $file"
aws s3 cp "$file" "s3://$bucket/" || die "upload failed: $file"
}
main() {
upload_artifact "$1" "${BUCKET:?BUCKET must be set}"
}
main "$@"Hay tres cosas ahí que merece la pena copiar. log escribe en stderr, así que nunca contamina los datos que el script emite por stdout. ${BUCKET:?BUCKET must be set} falla con ruido y con tu propio mensaje cuando falta la variable, en lugar de expandirse en silencio a nada. Y llamar a main "$@" en la última línea significa que todo el archivo se analiza antes de que se ejecute nada, así que un script guardado a mitad de edición no se ejecuta a medias.
Limpia con trap
Lo difícil de un script no es el camino feliz, es lo que pasa cuando muere a la mitad. Los archivos temporales, los directorios de bloqueo y las salidas a medio escribir se filtran si no se gestiona la salida de forma explícita.
trap ejecuta una función cuando el script termina por cualquier motivo: éxito, error o Ctrl-C. Combínalo con mktemp y la limpieza se dispara de verdad.
workdir="$(mktemp -d)"
cleanup() { rm -rf "$workdir"; }
trap cleanup EXIT
# hacer el trabajo en «$workdir». Desaparece sea cual sea la forma de salirUn solo trap sobre EXIT cubre una salida normal y la mayoría de las señales. Si necesitas distinguir un fallo de una ejecución limpia, intercepta ERR por separado y registra ahí. Lo importante es que la limpieza viva en un solo lugar, declarada de entrada, en lugar de copiarse y pegarse delante de cada exit.
Las trampas que sobreviven a set -e
set -e no es un campo de fuerza. Tiene agujeros bien conocidos, y hacer como si no existieran es como te muerde justo lo que creías cubierto.
- Un comando que falla en la condición de un
ifno provoca una salida. Es intencionado, pero significa queif grep -q foo file; thenno abortará aunquegrepfalle por un motivo distinto de «sin coincidencias». - Un comando dentro de una sustitución
$(...)puede tragarse su fallo según el contexto. Asigna primero, comprueba después. local x="$(cmd)"enmascara el código de salida decmd, porquelocalen sí tiene éxito. Separa la declaración:local x; x="$(cmd)".- Una aritmética como
((count++))devuelve un valor distinto de cero cuando el resultado es0, lo que bajoset -emata el script. Usacount=$((count + 1))en su lugar.
Nada de eso es un argumento para abandonar set -e. Es un argumento para conocer sus bordes. Una herramienta cuyos límites entiendes vale más que una herramienta en la que confías a ciegas.
Saber cuándo parar
Los scripts que sobreviven son los que se tratan como código de verdad: revisados, analizados con shellcheck y lo bastante pequeños como para caber en la cabeza. El listón de «solo un script de shell» es el de cualquier otra cosa que se ejecute en producción, porque a producción le da igual la diferencia.
Cuando un script crece más allá de unos cientos de líneas, o empieza a manejar estructuras de datos de verdad, esa es la señal para recurrir a un lenguaje con tipos y un ejecutor de pruebas. El shell es pegamento. Respeta aquello en lo que es realmente bueno, niégate a forzarlo más allá y ejecuta shellcheck en CI para que las trampas de arriba nunca lleguen a la rama principal.