====== 01 - Scripts con bash ====== La terminal repite trabajo: teclear de nuevo la misma secuencia de comandos es lento y propenso a erratas. Un **script** guarda esa secuencia en un fichero y la ejecuta en orden con un solo comando: el mismo resultado cada vez, y el fichero documenta cómo se consigue. **Bash** es la **shell** habitual de los sistemas Unix: el intérprete que lee y ejecuta comandos línea a línea. Un script de bash es eso mismo —una secuencia de comandos— guardada en un fichero. ===== 1. De comandos a script ===== La primera línea de un script es el **shebang**: indica con qué intérprete se ejecuta. #!/usr/bin/env bash Se guarda con extensión ''.sh'' y necesita permiso de ejecución: $ chmod +x saludo.sh # solo la primera vez $ ./saludo.sh Los comentarios (''#'') no se ejecutan: explican el qué y el porqué. Los scripts se leen tanto como se escriben. Cualquier secuencia de comandos cabe en un script. Un ejemplo cotidiano: automatizar compilar y ejecutar un programa: #!/usr/bin/env bash echo "Compilando..." javac -d bin src/Main.java echo "Ejecutando..." java -cp bin Main * ''javac -d bin'' compila a la carpeta ''bin''. * ''java -cp bin Main'' ejecuta la clase con ''bin'' en el *classpath*. * ''echo'' escribe en la salida estándar: los mensajes cuentan lo que el script hace. ===== 2. Variables ===== Un script suele tener valores que cambian: se guardan en **variables** y se asignan una vez, al principio. FUENTE="datos" * La asignación no lleva espacios alrededor del ''='': no es una comparación, es una asignación. * Para usarla, ''$FUENTE'', y siempre entre **comillas dobles**: así un valor con espacios no se rompe. * Por convención, en **MAYÚSCULAS**: distingue la configuración de las demás líneas. La **sustitución de comandos**, ''$( )'', mete en una variable la salida de un comando: la shell lo ejecuta cada vez que corre el script. HOY=$(date +%F) DESTINO="copias/copia-$HOY.tgz" echo "La copia de hoy irá a $DESTINO" ===== 3. Tuberías y redirecciones ===== La shell **conecta comandos**: la salida de uno puede alimentar al siguiente o terminar en un fichero. ^ Sintaxis ^ Efecto ^ | ''comando > fichero'' | escribe la salida en el fichero; si existe, lo sobreescribe | | ''comando >> fichero'' | la añade al final del fichero | | ''comando 2> fichero'' | escribe ahí la salida de **error** | | ''comando < fichero'' | lee la entrada del fichero | | ''comando1 \| comando2'' | **tubería**: la salida de uno es la entrada del otro | $ ls -l > listado.txt $ find . -name "*.bak" 2> errores.txt # los avisos de find, en un fichero $ history | grep docker $ ps aux | grep java * Los errores van por otra vía, el **error estándar**: por eso el ''2>''. Un fallo silencioso en la salida normal pasaría desapercibido. * Cada herramienta hace una cosa y la hace bien —''grep'' busca, ''wc'' cuenta, ''sort'' ordena—; la tubería las compone. ===== 4. Errores y códigos de salida ===== Cada comando termina con un **código de salida**: ''0'' si tuvo éxito, distinto de ''0'' si falló. En la terminal no se ve, pero es el contrato con el que comandos y scripts se comunican. $ cat noexiste.txt cat: noexiste.txt: No such file or directory $ echo $? # el código del último comando 1 * ''$?'': el código de salida del último comando. * ''exit n'': termina el script con ese código. ¿Qué ocurre cuando un comando falla y el script no lo recoge? Por defecto la shell sigue con la línea siguiente: los errores pasan si nadie los recoge. El comando **set** da órdenes a la propia shell sobre cómo comportarse mientras ejecuta el script —no es un comando más del script, es lo que cambia el marco en el que todo se ejecuta—. Se escribe al principio, con las opciones que se necesiten. ^ Opción ^ La shell pasa a… ^ | ''-e'' | detener el script en cuanto un comando falla: fallar pronto, nada de continuar sobre un estado a medias | | ''-u'' | tratar como error el uso de una variable sin definir: la errata en un nombre deja de ser una cadena vacía invisible | | ''-o pipefail'' | dar por fallida una tubería si falla *cualquiera* de sus comandos, no solo el último | La combinación habitual en la cabecera de un script: #!/usr/bin/env bash set -euo pipefail Cada opción cubre un modo distinto de esconder un fallo: * Sin ''-e'', un ''tar'' que falla deja la copia a medias y el script sigue como si nada. * Sin ''-u'', escribir ''$FUENT'' por ''$FUENTE'' produce una cadena vacía: los datos vacíos se usan sin que nadie se entere. * Sin ''-o pipefail'', el código de salida de una tubería es el del último comando: el fallo del primer eslabón se pierde si el último acaba bien. $ cat noexiste.txt | wc -l cat: noexiste.txt: No such file or directory 0 $ echo $? 0 $ set -o pipefail $ cat noexiste.txt | wc -l cat: noexiste.txt: No such file or directory 0 $ echo $? 1 ===== 5. Condicionales y bucles ===== ''if'' ejecuta un bloque si la condición se cumple. Los corchetes son un comando de test: necesitan espacios alrededor y las variables entre comillas. if [ -f "$FUENTE" ]; then tar -czf copia.tgz "$FUENTE" else echo "No existe $FUENTE" exit 1 fi ^ Test ^ Comprueba ^ | ''-f'' | que existe y es fichero | | ''-d'' | que existe y es directorio | | ''-z'' | que la cadena está vacía | | ''='' | que dos cadenas son iguales | | ''-eq'' | que dos números son iguales | | ''-gt'' | que el primero es mayor que el segundo | | ''-ge'' | que el primero es mayor o igual | | ''-lt'' | que el primero es menor | | ''-ne'' | que los dos números son distintos | ''for'' recorre una lista; con la sustitución de comandos, la lista puede ser la salida de ''find'': for f in $(find . -name "*.txt"); do echo "Texto: $f" done ''while'' repite mientras la condición tenga éxito; con ''read'', recorre líneas de un fichero: while read -r linea; do echo "$linea" done < ficheros.txt ===== 6. Los argumentos del script ===== Un script gana de verdad ante la terminal cuando admite argumentos como cualquier comando: ''./tareas.sh copia'' y ''./tareas.sh limpia'' son el mismo script con órdenes distintas. Cada argumento llega en una variable de posición: ''$1'' es el primero, ''$2'' el segundo... y ''$0'' es el nombre del propio script. #!/usr/bin/env bash echo "Script: $0" echo "Primero: $1" echo "Segundo: $2" $ ./saluda.sh hola adios Script: ./saluda.sh Primero: hola Segundo: adios **$#** cuenta cuántos argumentos han llegado. Con el test ''-gt'' del apartado anterior permite decidir si los hay: if [ $# -gt 0 ]; then echo "Tarea pedida: $1" else echo "No hay tarea" exit 1 fi **"$@"** son *todos* los argumentos de golpe. Su uso natural es reenviarlos en bloque a otro comando: con los argumentos entre comillas dobles, cada uno llega al comando tal y como lo escribió el usuario —un valor con espacios no se rompe en dos—. cp "$@" copias/ # los ficheros que hayan llegado, uno a uno ''shift'' descarta ''$1'' y desplaza el resto: el ''$2'' pasa a ser ''$1'', el ''$3'' el ''$2''... Es la forma de consumir el primer argumento cuando la orden está aparte de los datos: ''./repo.sh add fichero1 fichero2''. orden="$1" shift echo "Orden: $orden" echo "Resto: $#" # ya solo cuentan los argumentos que le siguen ''shift'' avisa y falla si no hay argumentos que descartar, y con ''set -e'' ese aviso detendría el script. Cuando el primer argumento *puede* no estar, el remate habitual es doble: la salida de error va al ''/dev/null'' —el desagüe donde se tira la que no interesa— y ''true'' —un comando que siempre tiene éxito— deja el script tranquilo: orden="${1:-}" shift 2>/dev/null || true La expansión **''${var:-valor}''** da un valor por defecto: se queda con ''valor'' si la variable está vacía o sin definir. Con los argumentos es casi obligatoria: si el script lleva ''set -u'' y se invoca sin argumentos, usar ''$1'' es usar una variable sin definir y el script se detendría nada más empezar. orden="${1:-}" # cadena vacía si no llega nada fichero="${2:-correo.txt}" ===== 7. Funciones ===== Una **función** agrupa una tarea bajo un nombre: se define una vez y el script la llama cuando la necesita. copia() { echo "▶ Copiando..." tar -czf "$DESTINO" "$FUENTE" } limpia() { echo "▶ Limpiando..." rm -f "$FUENTE"/*.tmp } * Una tarea, una función: el cuerpo del script se lee como la lista de tareas que hace. * El nombre elegido dice la tarea —''copia'', ''limpia''—, no el comando que la resuelve. ===== 8. Subcomandos con case ===== El script admite un **subcomando** que elige la tarea: es el patrón de cualquier herramienta de línea de comandos, ''git commit'', ''docker compose up''... Un comando, una tarea. El ''case'' compara el primer argumento con cada caso y ejecuta el bloque correspondiente: case "${1:-}" in copia) copia ;; limpia) limpia ;; *) uso exit 1 ;; esac * ''${1:-}'' y no ''$1'': si el script lleva ''set -u'' y se invoca sin argumentos, comparar ''$1'' es usar una variable sin definir y el script se detendría nada más empezar. * '';;'' cierra cada caso. * ''*)'' es el resto: sin subcomando o con uno erróneo, el script enseña su uso —''uso''— y termina con error. La función ''uso'' escribe el mensaje de ayuda. En varias líneas, mejor sin repetir ''echo'' a cada línea: con un **heredoc** (aquí-documento), el texto que sigue a ''< uso() { cat <<'FIN' uso: ./tareas.sh [argumentos...] copia copia de seguridad de FUENTE limpia borra los ficheros temporales FIN } * El texto va desde ''< @startuml !theme cerulean skinparam backgroundColor transparent skinparam shadowing false skinparam ArrowThickness 1 skinparam classAttributeIconSize 0 skinparam class { BackgroundColor #FEFEFE BorderColor #344883 ArrowColor #344883 FontColor #343A40 } skinparam classAttributeFontColor #343A40 skinparam stereotypeCBackgroundColor #344883 skinparam stereotypeCBorderColor #273768 skinparam package { BackgroundColor transparent BorderColor #344883 FontColor #343A40 } skinparam ArrowColor #344883 skinparam participant { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam actor { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam sequence { ArrowColor #344883 LifeLineBorderColor #94A3B8 GroupBorderColor #344883 GroupBackgroundColor #E9EDF7 } skinparam note { BackgroundColor #FEF9C3 BorderColor #B6C2DE FontColor #343A40 } skinparam activity { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 StartColor #344883 EndColor #344883 DiamondBackgroundColor #FEFEFE DiamondBorderColor #344883 DiamondFontColor #343A40 } skinparam component { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam database { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam node { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam cloud { BackgroundColor transparent BorderColor #344883 FontColor #344883 } skinparam rectangle { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam state { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } skinparam usecase { BackgroundColor #FEFEFE BorderColor #344883 FontColor #343A40 } start :./tareas.sh "${1:-}"; switch ($1) case (copia) :copia; case (limpia) :limpia; case (*) :uso; :exit 1; endswitch stop @enduml ===== 9. Componerlo todo ===== Todo junto, el esqueleto de un script con varias tareas que admite argumentos: #!/usr/bin/env bash set -euo pipefail # Configuración FUENTE="datos" HOY=$(date +%F) DESTINO="copias/copia-$HOY.tgz" # Ayuda uso() { cat <<'FIN' uso: ./tareas.sh [ficheros...] copia copia de seguridad; con ficheros, solo esos limpia borra los ficheros temporales FIN } # Tareas copia() { echo "▶ Copiando..." tar -czf "$DESTINO" "${@:-$FUENTE}" } limpia() { echo "▶ Limpiando..." rm -f "$FUENTE"/*.tmp } # Despacho comando="${1:-}" shift 2>/dev/null || true case "$comando" in copia) copia "$@" ;; limpia) limpia ;; *) uso exit 1 ;; esac * ''set -euo pipefail'' en la cabecera: ni erratas ni tuberías a medias pasan desapercibidas. * ''${1:-}'' + ''shift'': se saca el comando y ''"$@"'' se queda solo con el resto de los argumentos. * ''${@:-$FUENTE}'': el truco del valor por defecto, sobre la lista — sin ficheros, la copia es de todo ''$FUENTE''; con ellos, de los pedidos. * Las tareas que aprovechan los argumentos los reciben; las que no, ni se molestan. El esqueleto es siempre el mismo: cabecera robusta, configuración, ayuda, tareas, despacho. Cambiar de tarea solo toca las funciones y el ''case''; la forma del script no cambia.