Files
loogle-scripts/scripts/pi1-master/weekly-maintenance.sh
T

582 lines
17 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
set -uo pipefail
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
CONF_FILE="/etc/weekly-maintenance.conf"
LOG_FILE="/var/log/weekly-maintenance.log"
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;;
esac
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "▶ $title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "✓ $title completato"
else
ERRORS+=("$title (exit $rc)")
log "✗ $title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
# Elenco nomi container da escludere (immagini locali / no registry).
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
if image_is_ignored "$image"; then
disabled+=("$name")
fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
fi
}
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
expand_compose_image() {
local raw="$1"
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
done
[[ "$raw" == *'$'* ]] && return 1
printf '%s' "$raw"
}
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
failover_image_is_local_build() {
local image="$1"
local name="${image%%:*}"
image_is_ignored "$image" && return 0
case "$name" in
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
return 0
;;
esac
return 1
}
collect_failover_registry_images() {
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
local f line img expanded
[[ -d "$dir" ]] || return 1
while IFS= read -r f; do
[[ -f "$f" ]] || continue
while IFS= read -r line; do
[[ -z "$line" ]] && continue
img=$(expand_compose_image "$line") || continue
failover_image_is_local_build "$img" && continue
printf '%s\n' "$img"
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
}
pull_failover_standby_images() {
command -v docker >/dev/null 2>&1 || return 0
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
log "▶ Pull immagini failover (registry, anche senza container)"
append_report ""
append_report "=== Pull immagini failover (standby) ==="
append_report "Compose: $dir"
if [[ ! -d "$dir" ]]; then
WARNINGS+=("Directory compose failover assente: $dir")
append_report "Directory assente"
return 0
fi
local -a images=()
local img
while IFS= read -r img; do
[[ -z "$img" ]] && continue
images+=("$img")
done < <(collect_failover_registry_images | sort -u)
if ((${#images[@]} == 0)); then
WARNINGS+=("Nessuna immagine registry nei compose failover")
append_report "Lista vuota"
return 0
fi
local pulled=0 failed=0 skipped=0
local before after
for img in "${images[@]}"; do
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if [[ -n "$before" && "$before" == "$after" ]]; then
skipped=$((skipped + 1))
log " = $img (già aggiornata)"
append_report "unchanged: $img"
else
pulled=$((pulled + 1))
log " ↑ $img"
append_report "updated: $img"
fi
else
failed=$((failed + 1))
WARNINGS+=("docker pull fallito: $img")
log " ✗ $img"
fi
done
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
run_watchtower
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
pull_failover_standby_images
# 5. Audit residui
audit_docker
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))