Ya sabes escribir Bash y ya sabes escribirlo bien; lo que queda es juntarlo todo. Este módulo no enseña materia nueva: construye. Cada lección toma un problema real de Veloz Envíos y lo lleva desde el enunciado hasta una herramienta terminada, explicando en cada paso por qué se decide lo que se decide y de qué lección viene la técnica. Empezamos por el proyecto más contenido de los cinco, info-sistema.sh, que no por ello es trivial: es donde se aprende la separación entre recolectar y formatear, la decisión de diseño que reaparecerá en los cuatro proyectos siguientes.
Contenido
- El problema: tres necesidades, un solo script
- Requisitos escritos como lista comprobable
- Diseño: recolectar y formatear son dos cosas distintas
- Versión 1: la mínima que funciona
- Las fuentes de datos fiables
- Versión 2: una función por área que devuelve pares clave=valor
- Los tres formateadores
- Umbrales y colores
- La interfaz: opciones y ayuda
- Robustez: qué hacer cuando el dato no está
- Puesta en uso: inventariar la flota
- El problema: tres necesidades, un solo script
En Veloz Envíos, la información básica de un servidor hace falta en tres situaciones muy distintas:
| Situación | Quién lo lee | Qué formato necesita |
|---|---|---|
Diagnóstico rápido: «srv-veloz-02 va lento» |
Una persona, en un terminal | Texto legible, alineado, con lo anómalo destacado |
| Inventario de la flota: versiones, RAM, discos | Una hoja de cálculo o un awk |
Una línea por servidor, campos separados |
| Contexto adjunto a una alerta | La API interna y el webhook | JSON |
La tentación es escribir tres scripts, o uno con un if gigante y tres copias de cada consulta. Ambas cosas envejecen mal: el día que añades «carga media» hay que tocarlo en tres sitios y uno se olvida. La solución es la que ya usaste sin nombrarla en 04-02 al devolver valores por stdout: la recolección no sabe nada del formato de salida.
- Requisitos escritos como lista comprobable
Antes de escribir código, los requisitos, en forma de lista que se pueda marcar:
- [ ] Recoge identificación (host, distribución, kernel, tiempo encendido), hardware (CPU, RAM), disco, memoria y carga, red y estado de servicios.
- [ ] Tres formatos de salida:
texto,tsv,json; y se puede pedir una sola sección (--seccion disco). - [ ] No necesita
rootpara lo esencial y no falla si algo no está disponible. - [ ] Marca en la salida de texto los valores fuera de umbral, con color solo si la salida es un terminal y
NO_COLORno está definida. - [ ] Pasa
shellcheck -x, termina en menos de un segundo y se ejecuta por SSH en los tres servidores sin cambios.
Esta lista es el contrato. Cuando dudes de si una función debe existir, la respuesta está aquí; y cuando el script esté terminado, se recorre marcando casillas.
- Diseño: recolectar y formatear son dos cosas distintas
El script tiene dos mitades que se comunican por un formato interno muy simple: líneas clave=valor.
recolecta_identificacion / _hardware / _disco ──┐ ──► formatea_texto
recolecta_memoria_carga / _red / _servicios ──┴─► clave=valor ───┼─► formatea_tsv
──► formatea_jsonLas funciones de recolección escriben en stdout (04-02) y no imprimen ni una cabecera. Los formateadores leen ese flujo y deciden cómo se ve. Añadir un dato nuevo es añadir una línea en una función; añadir un formato nuevo es añadir una función que no toca ninguna consulta al sistema.
- Versión 1: la mínima que funciona
Empezamos por lo más pequeño que resuelve el caso 1 de la tabla. Nada de opciones, nada de formatos.
#!/usr/bin/env bash
# info-sistema.sh - v1: identificacion basica en texto.
set -euo pipefail
printf 'host=%s\n' "$(hostname -f)"
printf 'kernel=%s\n' "$(uname -r)"
printf 'uptime=%s\n' "$(uptime -p)"Veinte segundos de trabajo y ya es útil. Esta versión importa por dos razones. Primera: fija el formato interno (clave=valor) desde la primera línea, y todo lo demás crecerá encima. Segunda: es ejecutable y verificable ahora, no cuando esté «terminado». Construir incrementalmente significa que en ningún momento hay un script roto a medio escribir.
- Las fuentes de datos fiables
En 06-03 vimos la regla: lee ficheros, no salidas pensadas para humanos. Aquí se aplica en cada dato.
| Dato | Fuente elegida | Por qué no la alternativa |
|---|---|---|
| Distribución | /etc/os-release |
Es un fichero con formato CLAVE="valor", apto para source; lsb_release puede no estar instalado |
| Kernel | uname -r |
Salida de un solo campo, estable desde hace décadas |
| CPUs | nproc |
Contar líneas de /proc/cpuinfo da otro número con hyperthreading y contenedores |
| Memoria | /proc/meminfo |
free cambia de columnas entre versiones; MemAvailable es el número que de verdad importa |
| Carga | /proc/loadavg |
Un solo read frente a recortar uptime con sed |
| Disco | df -P |
-P fuerza el formato POSIX: una línea por sistema de ficheros, sin partirla cuando el dispositivo es largo |
| IP y servicios | ip -4 addr, systemctl is-active |
ifconfig está obsoleto en Ubuntu 24.04; is-active devuelve una palabra y un código de salida |
LC_ALL=C al principio del script (06-03) garantiza que los decimales lleven punto y que los mensajes de error no cambien con el idioma del servidor.
- Versión 2: una función por área que devuelve pares clave=valor
Cada función es corta, no imprime cabeceras y nunca aborta el script si su fuente falta.
recolecta_identificacion() {
[[ -r /etc/os-release ]] && . /etc/os-release # shellcheck disable=SC1091
printf 'host=%s\n' "$(hostname -f 2>/dev/null || hostname)"
printf 'so=%s\n' "${PRETTY_NAME:-desconocido}"
printf 'kernel=%s\n' "$(uname -r)"
printf 'uptime=%s\n' "$(uptime -p 2>/dev/null || echo n/d)"
}
recolecta_memoria_carga() {
local total dispo carga
read -r total dispo < <(awk '/^MemTotal:|^MemAvailable:/ {printf "%s ", $2}' /proc/meminfo)
read -r carga _ < /proc/loadavg
printf 'mem_total_mb=%s\n' "$((total / 1024))"
printf 'mem_usada_pct=%s\n' "$(veloz_porcentaje "$((total - dispo))" "$total")"
printf 'carga_1m=%s\n' "$carga"
printf 'carga_por_cpu=%s\n' "$(echo "$carga / $(nproc)" | bc -l | cut -c1-4)"
}
recolecta_disco() {
df -P -x tmpfs -x devtmpfs 2>/dev/null |
awk 'NR>1 {gsub(/%/,"",$5); printf "disco_%s_pct=%s\ndisco_%s_libre=%s\n", $6, $5, $6, $4}'
}
recolecta_servicios() {
local s
for s in "${SERVICIOS[@]}"; do # veloz-api, nginx, ssh...
printf 'servicio_%s=%s\n' "$s" "$(systemctl is-active "$s" 2>/dev/null || echo desconocido)"
done
}Cuatro detalles que vienen de lecciones anteriores. El . /etc/os-release es la técnica de 05-06 aplicada a un fichero de datos: en lugar de recortarlo con cut, se carga y quedan sus variables disponibles. veloz_porcentaje sale de lib/comun.sh y evita repetir aritmética entera (04-06). El awk de recolecta_disco hace en una pasada lo que un bucle while read haría en veinte líneas (06-01), y el gsub quita el % para que el valor sea numérico y comparable. Y el || echo desconocido en systemctl es la respuesta al requisito de no fallar en un contenedor sin systemd: mejor un valor honesto que una muerte por set -e.
- Los tres formateadores
Reciben el flujo clave=valor por la entrada estándar. Ninguno consulta nada al sistema.
formatea_texto() {
local clave valor
while IFS='=' read -r clave valor; do
printf ' %-22s %s%s%s\n' "$clave" "$(color_de "$clave" "$valor")" "$valor" "$RESET"
done
}
formatea_tsv() {
local clave valor fila=()
while IFS='=' read -r clave valor; do fila+=("$valor"); done
local IFS=$'\t'; printf '%s\n' "${fila[*]}"
}
formatea_json() {
local args=() clave valor
while IFS='=' read -r clave valor; do args+=(--arg "$clave" "$valor"); done
jq -n "${args[@]}" '$ARGS.named'
}formatea_texto usa printf con ancho fijo %-22s (04-04) para que las claves queden alineadas sin column. formatea_tsv acumula en un array y aprovecha que "${fila[*]}" une los elementos con el primer carácter de IFS (03-06): asignar IFS=$'\t' como local hace que el separador sea un tabulador solo dentro de la función. Y formatea_json construye los argumentos de jq -n --arg en un array (06-05) para que el escapado de comillas, acentos y barras lo haga jq y no nosotros; $ARGS.named es el objeto con todos los --arg recibidos. Escribir ese JSON a mano con printf sería la forma más rápida de generar JSON inválido el día que un hostname lleve un guion raro.
- Umbrales y colores
Los umbrales viven en una sola tabla, no repartidos por el código:
declare -A UMBRAL=([mem_usada_pct]=85 [carga_por_cpu]=1.5)
color_de() { # $1=clave $2=valor -> secuencia de color o nada
[[ $COLOR == no ]] && return 0
local limite=${UMBRAL[$1]:-}
[[ -z $limite ]] && { [[ $1 == disco_*_pct ]] && limite=90 || return 0; }
awk -v v="$2" -v l="$limite" 'BEGIN {exit !(v+0 > l)}' && printf '%s' "$ROJO"
}La comparación se delega en awk porque los valores pueden ser decimales y [[ ]] solo compara enteros (04-06). El +0 fuerza a número, de modo que un valor no numérico como desconocido se evalúe como 0 y nunca se pinte de rojo. La decisión sobre el color, en cambio, se toma una sola vez, al arrancar (03-05):
COLOR=no; ROJO=''; RESET=''
[[ -t 1 && -z ${NO_COLOR:-} && $FORMATO == texto ]] &&
{ COLOR=si; ROJO=$'\033[31m'; RESET=$'\033[0m'; }Tres condiciones y las tres importan. -t 1 detecta que stdout es un terminal: si el script se redirige a un fichero o se encadena con grep, los códigos de escape ensuciarían el resultado. NO_COLOR es una convención respetada por muchas herramientas y cuesta una línea. Y el color se desactiva siempre en TSV y JSON, porque ahí la salida la lee una máquina.
- La interfaz: opciones y ayuda
uso() {
cat <<'EOF'
Uso: info-sistema.sh [opciones]
-f, --formato texto|tsv|json Formato de salida (defecto: texto)
-s, --seccion NOMBRE Solo una seccion; repetible
-h, --ayuda Esta ayuda
EOF
}
FORMATO=texto; SECCIONES=()
while [[ $# -gt 0 ]]; do
case $1 in
-f|--formato) FORMATO=${2:?falta valor para --formato}; shift 2 ;;
-s|--seccion) SECCIONES+=("${2:?falta valor}"); shift 2 ;;
-h|--ayuda) uso; exit 0 ;;
--) shift; break ;;
-*) veloz_morir 2 "opcion desconocida: $1" ;;
*) break ;;
esac
done
[[ $FORMATO =~ ^(texto|tsv|json)$ ]] || veloz_morir 2 "formato invalido: $FORMATO"Se elige el bucle while+case sobre getopts (03-05) por una razón concreta: queremos opciones largas legibles en el runbook, y getopts solo maneja las cortas. El ${2:?...} corta de raíz el error clásico de --formato sin valor. El -- explícito permite que un día haya argumentos posicionales. Y validar $FORMATO con una expresión regular (05-04) en lugar de con un case en el despachador significa que el error se detecta al arrancar, no a mitad de la ejecución.
El despachador principal recorre las secciones pedidas y canaliza todo hacia un único formateador:
main() {
veloz_requiere jq awk df
local sec
[[ ${#SECCIONES[@]} -eq 0 ]] && SECCIONES=("${TODAS[@]}")
{
for sec in "${SECCIONES[@]}"; do
declare -F "recolecta_$sec" >/dev/null || veloz_morir 2 "seccion desconocida: $sec"
"recolecta_$sec"
done
} | "formatea_$FORMATO"
}
main "$@"La llave que agrupa el bucle (05-05) permite que toda la salida del bucle entre por una sola tubería al formateador. Y declare -F comprueba que la función existe antes de invocarla: la sección se convierte en nombre de función, pero solo si esa función está definida, que es la lista blanca de 08-03 aplicada sin escribir la lista dos veces.
- Robustez: qué hacer cuando el dato no está
set -euo pipefail mata el script ante cualquier fallo, y eso es correcto para errores de programación pero desastroso para un dato ausente. La regla del proyecto: un dato que falta es n/d, no una muerte.
| Situación | Efecto sin protección | Qué hacemos |
|---|---|---|
Contenedor sin systemctl, máquina sin ip |
command not found y salida 127 |
command -v previo o 2>/dev/null || echo n/d |
jq no instalado |
JSON inválido a medio escribir | veloz_requiere jq al arrancar |
df sobre un montaje NFS colgado |
El script se queda bloqueado | timeout 5 df -P (05-02) |
El último caso es el más traicionero: un dato que no llega nunca es peor que un dato que falla, porque un script colgado no genera alerta. timeout convierte el bloqueo en un error visible.
- Puesta en uso: inventariar la flota
Con el formato TSV, el inventario de los tres servidores es una línea reutilizando flota.sh (07-06):
~/veloz-ops/bin/flota.sh 'bash -s' < ~/veloz-ops/bin/info-sistema.sh --formato tsv | column -t -s$'\t'Se envía el script por la entrada estándar en lugar de instalarlo en cada servidor: así el inventario refleja siempre la última versión y no hay que sincronizar nada. Y para adjuntar contexto a una alerta, info-sistema.sh --formato json --seccion memoria_carga produce exactamente el objeto que espera el webhook de 07-04.
Errores Comunes y Consejos
- Mezclar recolección y formato. Si una función de recolección imprime
printf 'Memoria: %s%%', el JSON queda inservible. La prueba de fuego: ninguna funciónrecolecta_*debe contener espacios de alineación, dos puntos ni colores. - Construir JSON a mano.
printf '{"host":"%s"}'funciona hasta que un valor lleve una comilla o una barra.jq -n --argsiempre. - Comparar decimales con
[[ ]].[[ 1.5 -gt 1 ]]no da error de sintaxis, da un error de ejecución y conset -emata el script. Decimales, conawkobc. - Olvidar
-PendfoLC_ALL=Cen los decimales. Sin-P, un dispositivo de nombre largo parte la línea en dos y elawklee la columna equivocada. - Colorear sin comprobar
-t 1. El día que alguien hagainfo-sistema.sh > estado.txt, el fichero se llena de\033[31m. - Consejo: cronometra el script con
time(08-02). Si tarda más de un segundo, casi seguro es unforque lanza un proceso por dato; agrúpalo en un soloawk.
Ejercicios
- Sección de paquetes. Añade
recolecta_paquetes, que informe depaquetes_instaladosypaquetes_actualizables(usaapt list --upgradableen Ubuntu), y derequiere_reiniciosegún exista/var/run/reboot-required. Debe degradar an/den un sistema sinapt. - Comparar dos ejecuciones. Escribe el subcomando
--comparar FICHERO: guarda una ejecución enclave=valory, al invocarlo, muestra solo las claves cuyo valor haya cambiado respecto al fichero, con el formatoclave: antes -> ahora. - Sección de temperatura. Añade
recolecta_temperaturaleyendo/sys/class/thermal/thermal_zone*/temp(milésimas de grado) con globbing (02-05) y$(<fichero)en vez decat(08-02), y marca en rojo por encima de 75 °C añadiendo su clave a la tablaUMBRAL, sin tocarcolor_de.
Soluciones
1. La clave es aislar cada consulta con su propia salvaguarda:
recolecta_paquetes() {
local inst=n/d act=n/d
if command -v dpkg-query >/dev/null; then
inst=$(dpkg-query -f '.\n' -W 2>/dev/null | wc -l)
act=$(apt list --upgradable 2>/dev/null | grep -c upgradable || true)
fi
printf 'paquetes_instalados=%s\npaquetes_actualizables=%s\n' "$inst" "$act"
printf 'requiere_reinicio=%s\n' "$([[ -f /var/run/reboot-required ]] && echo si || echo no)"
}El || true tras grep -c es imprescindible: grep devuelve 1 cuando no encuentra nada, y con set -e eso sería el fin del script aunque «cero actualizables» sea la mejor noticia posible.
2. Se aprovecha que el formato interno ya es clave=valor, así que basta con un array asociativo (04-03):
formatea_comparar() { # $REFERENCIA = fichero de una ejecucion anterior
declare -A antes; local k v
while IFS='=' read -r k v; do antes[$k]=$v; done < "$REFERENCIA"
while IFS='=' read -r k v; do
[[ ${antes[$k]:-<nuevo>} != "$v" ]] && printf '%s: %s -> %s\n' "$k" "${antes[$k]:-<nuevo>}" "$v"
done
return 0
}Que sea un formateador más es la ventaja de haber separado las dos mitades: comparar no ha requerido tocar ni una consulta al sistema.
3. El bucle recorre /sys/class/thermal/thermal_zone*/temp con [[ -r $z ]] || continue para saltar las zonas ilegibles, y emite printf 'temp_%s=%s\n' "$n" "$(( $(<"$z") / 1000 ))". Añadir [temp_0]=75 a UMBRAL basta para que el coloreado funcione solo.
Conclusión
info-sistema.sh está terminado y cumple las ocho casillas del contrato del apartado 2. Más importante que el script es la decisión de diseño que lo vertebra: separar la recolección del formateo, con un formato interno mínimo (clave=valor) como frontera entre ambas. Gracias a ella, tres formatos de salida conviven sin una sola consulta duplicada, añadir un dato es una línea, añadir un formato es una función, y comparar dos ejecuciones —el ejercicio 2— se resolvió sin tocar la recolección. El resto del proyecto ha sido aplicar lo aprendido: fuentes fiables de /proc y ficheros en vez de salidas para humanos (06-03), printf con anchos (04-04), jq -n --arg (06-05), lista blanca vía declare -F (08-03), timeout contra los bloqueos (05-02) y una interfaz con opciones largas y validación temprana (03-05). Y una regla de operación que vale para todo el módulo: un dato que falta es n/d, no una excepción.
En 09-02 el material de entrada deja de ser un puñado de ficheros de /proc con una línea cada uno y pasa a ser un millón de líneas de acceso.log y app.log. Construiremos analiza-logs.sh: una sola pasada con awk, lectura transparente de rotados y comprimidos, filtros por fecha y nivel, agregaciones, histograma dibujado con printf, detección de anomalías e informe en texto y JSON —el proyecto más exigente del curso en procesamiento de texto.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
