nodo-mantenimiento
#!/bin/bash
# Mantenimiento del sistema de un nodo. Se instala en /usr/local/sbin (root:root 0755)
# con ansible/nodo/preparar_nodo.sh, y el usuario `ansible` solo puede lanzarlo con
# sudo y con una de estas acciones exactas (ver /etc/sudoers.d/nodo-mantenimiento):
#
# revisar apt-get update y deja las métricas de actualizaciones para node-exporter
# actualizar instala todas las actualizaciones (apt-get upgrade --with-new-pkgs)
# tailscale actualiza solo Tailscale
# reiniciar reinicia en 10 s, solo si el sistema lo pide (/var/run/reboot-required)
# k3s actualiza el binario de k3s a la versión que pide el portal (ver abajo)
#
# No acepta otros argumentos ni lee nada del entorno. La última línea de la salida es
# un JSON con el estado del nodo, que leen los playbooks.
#
# k3s: la versión NO llega como argumento (el sudo sigue siendo de órdenes exactas): la deja
# el playbook en ~ansible/k3s-version y aquí se valida al máximo: formato v1.X.Y+k3sN, nunca
# hacia atrás, como mucho una rama más, binario solo de GitHub de k3s y con su sha256.
set -euo pipefail
# /usr/local/bin: ahí instala k3s su binario (root:root).
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C DEBIAN_FRONTEND=noninteractive
umask 022
TEXTFILE=/var/lib/node_exporter/textfile
METRICAS="$TEXTFILE/actualizaciones.prom"
APT_OPTS=(-q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
[ "$(id -u)" -eq 0 ] || { echo "hay que lanzarlo con sudo" >&2; exit 77; }
[ "$#" -eq 1 ] || { echo "uso: $0 revisar|actualizar|tailscale|reiniciar|k3s" >&2; exit 64; }
# Una sola ejecución a la vez (el temporizador y Ansible podrían coincidir).
exec 9>/run/nodo-mantenimiento.lock
flock -w 600 9 || { echo "otra ejecución sigue en marcha" >&2; exit 75; }
apt_update() {
# Reintenta si apt está ocupado (unattended-upgrades).
apt-get "${APT_OPTS[@]}" -o DPkg::Lock::Timeout=300 update >/dev/null
}
escapar() { sed 's/\\/\\\\/g; s/"/\\"/g'; }
# Escribe las métricas y saca el JSON de estado. Solo lee.
estado() {
local nodo pend seg norm reinicio auto ts k3s kernel os ahora tmp
nodo=$(hostname)
# Inst paquete [versión actual] (versión nueva origen [arquitectura])
# Seguridad = repositorio oficial *-security (resolute-security, Debian-Security…), no
# cualquiera que lleve «security» en el nombre (p. ej. el de Zeek, «security:zeek»).
pend=$(apt-get -s -o Debug::NoLocking=1 upgrade --with-new-pkgs 2>/dev/null | awk '/^Inst /{
nueva=$0; sub(/^[^(]*\(/, "", nueva); split(nueva, v, " ");
print $2, v[1], (tolower(nueva) ~ /-security/ ? 1 : 0) }')
seg=$(awk '$3==1' <<<"$pend" | grep -c . || true)
norm=$(awk '$3==0' <<<"$pend" | grep -c . || true)
[ -e /var/run/reboot-required ] && reinicio=1 || reinicio=0
grep -qs 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades && auto=1 || auto=0
ts=$(command -v tailscale >/dev/null && tailscale version 2>/dev/null | head -1 || echo "")
k3s=$(command -v k3s >/dev/null && k3s --version 2>/dev/null | awk 'NR==1{print $3}' || echo "")
kernel=$(uname -r)
os=$(. /etc/os-release && echo "$PRETTY_NAME")
ahora=$(date +%s)
install -d -m 755 "$TEXTFILE"
tmp=$(mktemp "$TEXTFILE/.actualizaciones.XXXXXX")
{
echo "# HELP nodo_actualizaciones_pendientes Paquetes con actualización pendiente."
echo "# TYPE nodo_actualizaciones_pendientes gauge"
echo "nodo_actualizaciones_pendientes{nodo=\"$nodo\",tipo=\"seguridad\"} $seg"
echo "nodo_actualizaciones_pendientes{nodo=\"$nodo\",tipo=\"normal\"} $norm"
echo "# HELP nodo_actualizacion_paquete Un paquete con actualización pendiente."
echo "# TYPE nodo_actualizacion_paquete gauge"
while read -r paquete version es_seg; do
[ -n "$paquete" ] || continue
echo "nodo_actualizacion_paquete{nodo=\"$nodo\",paquete=\"$(escapar <<<"$paquete")\",version=\"$(escapar <<<"$version")\",seguridad=\"$es_seg\"} 1"
done <<<"$pend"
echo "# HELP nodo_reinicio_pendiente 1 si el sistema pide reiniciar."
echo "# TYPE nodo_reinicio_pendiente gauge"
echo "nodo_reinicio_pendiente{nodo=\"$nodo\"} $reinicio"
echo "# HELP nodo_actualizaciones_automaticas 1 si unattended-upgrades está activo."
echo "# TYPE nodo_actualizaciones_automaticas gauge"
echo "nodo_actualizaciones_automaticas{nodo=\"$nodo\"} $auto"
echo "# HELP nodo_version_info Versiones de los componentes del nodo."
echo "# TYPE nodo_version_info gauge"
for c in "tailscale=$ts" "k3s=$k3s" "kernel=$kernel" "sistema=$os"; do
echo "nodo_version_info{nodo=\"$nodo\",componente=\"${c%%=*}\",version=\"$(escapar <<<"${c#*=}")\"} 1"
done
echo "# HELP nodo_actualizaciones_revisado_timestamp_seconds Última revisión."
echo "# TYPE nodo_actualizaciones_revisado_timestamp_seconds gauge"
echo "nodo_actualizaciones_revisado_timestamp_seconds{nodo=\"$nodo\"} $ahora"
} >"$tmp"
chmod 644 "$tmp"
mv -f "$tmp" "$METRICAS" # atómico: node-exporter nunca lee un fichero a medias
local paquetes
paquetes=$(awk 'NF{printf "%s{\"paquete\":\"%s\",\"version\":\"%s\",\"seguridad\":%s}", (n++?",":""), $1, $2, ($3?"true":"false")}' <<<"$pend")
printf '{"nodo":"%s","seguridad":%d,"normales":%d,"reinicio":%s,"tailscale":"%s","k3s":"%s","kernel":"%s","paquetes":[%s]}\n' \
"$nodo" "$seg" "$norm" "$([ "$reinicio" = 1 ] && echo true || echo false)" "$ts" "$k3s" "$kernel" "$paquetes"
}
# Actualiza k3s en este nodo (master: snapshot de etcd antes). Solo el binario: conserva
# el unit de systemd y sus flags (docs/operacion.md).
actualizar_k3s() {
local pedida actual arch bin svc rol tmp u
local f=/var/lib/ansible/k3s-version
# El fichero es del usuario ansible: nada de enlaces (podrían apuntar a /etc/shadow) y
# nunca se muestra lo leído si no es una versión válida.
[ -f "$f" ] && [ ! -L "$f" ] && [ "$(stat -c %U "$f")" = ansible ] \
|| { echo "falta $f o no es un fichero normal de ansible" >&2; exit 65; }
pedida=$(head -c 64 "$f" | tr -d '[:space:]')
[[ "$pedida" =~ ^v1\.([0-9]{1,2})\.([0-9]{1,3})\+k3s([0-9]{1,2})$ ]] \
|| { echo "versión no válida en $f" >&2; exit 65; }
local p_minor=${BASH_REMATCH[1]}
actual=$(/usr/local/bin/k3s --version | awk 'NR==1{print $3}')
[[ "$actual" =~ ^v1\.([0-9]+)\. ]] || { echo "no sé leer la versión actual: $actual" >&2; exit 70; }
local a_minor=${BASH_REMATCH[1]}
if [ "$pedida" = "$actual" ]; then echo "k3s ya está en $actual"; return 0; fi
[ "$(printf '%s\n%s\n' "$actual" "$pedida" | sort -V | tail -1)" = "$pedida" ] \
|| { echo "no se baja de versión: $actual → $pedida" >&2; exit 65; }
[ "$p_minor" -le $((a_minor + 1)) ] \
|| { echo "como mucho una rama más: $actual → $pedida" >&2; exit 65; }
case "$(uname -m)" in
x86_64) arch=amd64; bin=k3s ;;
aarch64) arch=arm64; bin=k3s-arm64 ;;
*) echo "arquitectura no soportada" >&2; exit 70 ;;
esac
if [ -f /etc/systemd/system/k3s.service ]; then rol=master; svc=k3s; else rol=worker; svc=k3s-agent; fi
echo "k3s $actual → $pedida ($rol, $arch)"
u="https://github.com/k3s-io/k3s/releases/download/${pedida/+/%2B}"
tmp=$(mktemp -d /var/tmp/k3s-nuevo.XXXXXX)
curl -sfLo "$tmp/$bin" --proto '=https' "$u/$bin"
curl -sfL --proto '=https' "$u/sha256sum-$arch.txt" | grep " $bin\$" \
| sed "s| $bin\$| $tmp/$bin|" | sha256sum -c - >/dev/null
chmod 755 "$tmp/$bin"
"$tmp/$bin" --version | head -1 | grep -qF "$pedida" || { echo "el binario no es $pedida" >&2; exit 70; }
if [ "$rol" = master ]; then
/usr/local/bin/k3s etcd-snapshot save --name "pre-${pedida//+/-}" >/dev/null
echo "snapshot de etcd: pre-${pedida//+/-}"
fi
cp -a /usr/local/bin/k3s /usr/local/bin/k3s.anterior.bak
install -m 755 "$tmp/$bin" /usr/local/bin/k3s
rm -rf "$tmp"
systemctl restart "$svc"
for _ in $(seq 60); do systemctl is-active --quiet "$svc" && break; sleep 5; done
if ! systemctl is-active --quiet "$svc"; then
# Vuelta atrás automática al binario anterior.
install -m 755 /usr/local/bin/k3s.anterior.bak /usr/local/bin/k3s
systemctl restart "$svc"
echo "$svc no arrancaba con $pedida: vuelto a $actual" >&2
exit 70
fi
echo "k3s instalado y $svc en marcha"
}
case "$1" in
revisar)
apt_update
estado
;;
actualizar)
# Si una actualización anterior se cortó (apagón, reinicio…), apt se niega a
# seguir hasta terminar de configurar lo desempaquetado. Es lo mismo que hace
# unattended-upgrades antes de empezar.
# Sin terminal: conserva los ficheros de configuración locales, como APT_OPTS.
# dpkg no espera al bloqueo como apt (DPkg::Lock::Timeout): se reintenta 5 min.
for _ in $(seq 60); do
if salida=$(dpkg --force-confdef --force-confold --configure -a 2>&1); then
[ -z "$salida" ] || echo "$salida"
break
fi
grep -qi "lock" <<<"$salida" || { echo "$salida" >&2; exit 1; }
sleep 5
done
apt-get "${APT_OPTS[@]}" -o DPkg::Lock::Timeout=300 -y -f install
apt_update
apt-get "${APT_OPTS[@]}" -o DPkg::Lock::Timeout=300 -y upgrade --with-new-pkgs
apt-get "${APT_OPTS[@]}" -y autoremove --purge
estado
;;
tailscale)
apt_update
if dpkg -s tailscale >/dev/null 2>&1; then
apt-get "${APT_OPTS[@]}" -o DPkg::Lock::Timeout=300 -y install --only-upgrade tailscale
fi
estado
;;
reiniciar)
if [ -e /var/run/reboot-required ]; then
systemd-run --quiet --on-active=10 --unit=nodo-mantenimiento-reinicio systemctl reboot
echo '{"reinicio":"en 10 s"}'
else
echo '{"reinicio":"no hace falta"}'
fi
;;
k3s)
actualizar_k3s
estado
;;
*)
echo "acción desconocida: $1 (revisar|actualizar|tailscale|reiniciar|k3s)" >&2
exit 64
;;
esac