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