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

  1. El problema: tres necesidades, un solo script
  2. Requisitos escritos como lista comprobable
  3. Diseño: recolectar y formatear son dos cosas distintas
  4. Versión 1: la mínima que funciona
  5. Las fuentes de datos fiables
  6. Versión 2: una función por área que devuelve pares clave=valor
  7. Los tres formateadores
  8. Umbrales y colores
  9. La interfaz: opciones y ayuda
  10. Robustez: qué hacer cuando el dato no está
  11. Puesta en uso: inventariar la flota

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

  1. 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 root para 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_COLOR no 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.

  1. 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_json

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

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

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

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

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

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

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

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

  1. 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ón recolecta_* 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 --arg siempre.
  • Comparar decimales con [[ ]]. [[ 1.5 -gt 1 ]] no da error de sintaxis, da un error de ejecución y con set -e mata el script. Decimales, con awk o bc.
  • Olvidar -P en df o LC_ALL=C en los decimales. Sin -P, un dispositivo de nombre largo parte la línea en dos y el awk lee la columna equivocada.
  • Colorear sin comprobar -t 1. El día que alguien haga info-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 un for que lanza un proceso por dato; agrúpalo en un solo awk.

Ejercicios

  1. Sección de paquetes. Añade recolecta_paquetes, que informe de paquetes_instalados y paquetes_actualizables (usa apt list --upgradable en Ubuntu), y de requiere_reinicio según exista /var/run/reboot-required. Debe degradar a n/d en un sistema sin apt.
  2. Comparar dos ejecuciones. Escribe el subcomando --comparar FICHERO: guarda una ejecución en clave=valor y, al invocarlo, muestra solo las claves cuyo valor haya cambiado respecto al fichero, con el formato clave: antes -> ahora.
  3. Sección de temperatura. Añade recolecta_temperatura leyendo /sys/class/thermal/thermal_zone*/temp (milésimas de grado) con globbing (02-05) y $(<fichero) en vez de cat (08-02), y marca en rojo por encima de 75 °C añadiendo su clave a la tabla UMBRAL, sin tocar color_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

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados