Files
s3_to_folder_sync/sync.sh
2026-03-18 13:49:16 +01:00

604 lines
19 KiB
Bash

#!/usr/bin/env bash
# ============================================================
# sync.sh - Script di sincronizzazione S3 → Locale
# ============================================================
# Configura rclone dinamicamente dalle variabili d'ambiente
# e avvia un loop di sincronizzazione periodica.
# Scrive stato strutturato in /data/state/ per la dashboard web.
# ============================================================
set -euo pipefail
# --- Colori per il log (solo su terminale, non nel file di log) ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# --- Directory di stato condivisa con il web server ---
STATE_DIR="${STATE_DIR:-/data/state}"
LOG_FILE="${STATE_DIR}/sync.log"
STATUS_FILE="${STATE_DIR}/status.json"
HISTORY_FILE="${STATE_DIR}/history.json"
CHANGES_FILE="${STATE_DIR}/recent_changes.json"
# Crea la directory di stato
mkdir -p "${STATE_DIR}"
# Inizializza i file JSON se non esistono
[ -f "${HISTORY_FILE}" ] || echo '[]' > "${HISTORY_FILE}"
[ -f "${CHANGES_FILE}" ] || echo '[]' > "${CHANGES_FILE}"
# --- Funzioni di logging ---
# Scrive sia su stdout (con colori) che sul file di log (senza colori)
log_info() {
local msg="[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $*"
echo -e "${GREEN}${msg}${NC}"
echo "${msg}" >> "${LOG_FILE}"
}
log_warn() {
local msg="[WARN] $(date '+%Y-%m-%d %H:%M:%S') - $*"
echo -e "${YELLOW}${msg}${NC}"
echo "${msg}" >> "${LOG_FILE}"
}
log_error() {
local msg="[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $*"
echo -e "${RED}${msg}${NC}"
echo "${msg}" >> "${LOG_FILE}"
}
# --- Funzioni di stato (scrivono JSON per la dashboard) ---
write_status() {
# Scrive lo stato corrente in formato JSON
local state="$1"
local message="${2:-}"
local now
now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
cat > "${STATUS_FILE}" <<ENDJSON
{
"state": "${state}",
"message": "${message}",
"last_sync": ${LAST_SYNC_TS:-null},
"updated_at": "${now}",
"sync_count": ${SYNC_COUNT:-0},
"error_count": ${ERROR_COUNT:-0}
}
ENDJSON
}
append_history() {
# Aggiunge un record allo storico delle sincronizzazioni (max 50 entries)
local success="$1"
local duration="$2"
local transferred="${3:-0}"
local size="${4:-0 B}"
local time_str
time_str="$(date '+%Y-%m-%d %H:%M:%S')"
# Leggi storico attuale, aggiungi in testa, limita a 50
python3 -c "
import json, sys
try:
with open('${HISTORY_FILE}', 'r') as f:
history = json.load(f)
except:
history = []
entry = {
'time': '${time_str}',
'success': ${success},
'duration': '${duration}',
'transferred': '${transferred}',
'size': '${size}'
}
history.insert(0, entry)
history = history[:50]
with open('${HISTORY_FILE}', 'w') as f:
json.dump(history, f)
"
}
update_recent_changes() {
# Confronta lo stato prima/dopo la sync per trovare le modifiche
local after_snapshot="$1"
local before_snapshot="$2"
python3 -c "
import json, os
from datetime import datetime
after_file = '${after_snapshot}'
before_file = '${before_snapshot}'
def read_snapshot(path):
result = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if line:
parts = line.split('|', 2)
if len(parts) == 3:
result[parts[2]] = {'size': int(parts[0]), 'mtime': parts[1]}
except:
pass
return result
def format_size(b):
for u in ['B','KB','MB','GB','TB']:
if b < 1024: return f'{b:.1f} {u}'
b /= 1024
return f'{b:.1f} PB'
before = read_snapshot(before_file)
after = read_snapshot(after_file)
changes = []
now = datetime.now().strftime('%H:%M:%S')
# File nuovi
for path in after:
if path not in before:
changes.append({
'action': 'new',
'path': path,
'size': format_size(after[path]['size']),
'time': now
})
# File modificati
for path in after:
if path in before and (after[path]['size'] != before[path]['size'] or after[path]['mtime'] != before[path]['mtime']):
changes.append({
'action': 'modified',
'path': path,
'size': format_size(after[path]['size']),
'time': now
})
# File eliminati (solo in modalità mirror)
for path in before:
if path not in after:
changes.append({
'action': 'deleted',
'path': path,
'size': format_size(before[path]['size']),
'time': now
})
# Mantieni le ultime 100 modifiche
try:
with open('${CHANGES_FILE}', 'r') as f:
old_changes = json.load(f)
except:
old_changes = []
all_changes = changes + old_changes
all_changes = all_changes[:100]
with open('${CHANGES_FILE}', 'w') as f:
json.dump(all_changes, f)
"
}
take_snapshot() {
# Scatta una "foto" dello stato dei file locali: size|mtime|path
local output_file="$1"
find "${LOCAL_PATH}" -type f -printf '%s|%T@|%P\n' 2>/dev/null > "${output_file}" || true
}
# Tronca il file di log se troppo grande (>5MB)
truncate_log_if_needed() {
if [ -f "${LOG_FILE}" ]; then
local size
size=$(stat -f%z "${LOG_FILE}" 2>/dev/null || stat -c%s "${LOG_FILE}" 2>/dev/null || echo 0)
if [ "${size}" -gt 5242880 ]; then
# Mantieni solo le ultime 1000 righe
tail -n 1000 "${LOG_FILE}" > "${LOG_FILE}.tmp"
mv "${LOG_FILE}.tmp" "${LOG_FILE}"
log_info "File di log troncato (superato 5MB)"
fi
fi
}
# ============================================================
# Notifiche Gotify
# ============================================================
# Invia UNA SOLA notifica per ciclo di sync, al termine del
# processo, con riepilogo completo del lavoro eseguito.
# ============================================================
GOTIFY_ENABLED="${GOTIFY_ENABLED:-false}"
GOTIFY_URL="${GOTIFY_URL:-}"
GOTIFY_TOKEN="${GOTIFY_TOKEN:-}"
GOTIFY_PRIORITY="${GOTIFY_PRIORITY:-5}"
gotify_send() {
# Invia una notifica push a Gotify
# Parametri: $1 = titolo, $2 = messaggio, $3 = priorità (opzionale)
local title="$1"
local message="$2"
local priority="${3:-${GOTIFY_PRIORITY}}"
# Non inviare se Gotify è disabilitato o non configurato
if [ "${GOTIFY_ENABLED}" != "true" ] || [ -z "${GOTIFY_URL}" ] || [ -z "${GOTIFY_TOKEN}" ]; then
return 0
fi
# Invia la notifica in background per non bloccare la sync
# Usa il formato Markdown per il messaggio
wget -q --timeout=10 --tries=2 -O /dev/null \
--post-data="$(printf '{"title":"%s","message":"%s","priority":%s,"extras":{"client::display":{"contentType":"text/markdown"}}}' \
"${title}" "${message}" "${priority}")" \
--header="Content-Type: application/json" \
"${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}" 2>/dev/null &
log_info "Notifica Gotify inviata: ${title}"
}
gotify_notify_sync_result() {
# Notifica unica a fine processo di sync con riepilogo completo
# Parametri: $1 = success (true/false), $2 = durata, $3 = snap_after, $4 = snap_before
local success="$1"
local duration="$2"
local after_snap="${3:-}"
local before_snap="${4:-}"
# Info pianificazione
local schedule_info
if [ "${SCHEDULE_MODE:-interval}" = "cron" ]; then
schedule_info="${SCHEDULE_HUMAN:-cron}"
else
schedule_info="ogni ${SYNC_INTERVAL}s"
fi
if [ "${success}" = "true" ]; then
# --- Sync riuscita: calcola riepilogo modifiche ---
local summary
summary=$(python3 -c "
import sys
def read_snapshot(path):
result = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if line:
parts = line.split('|', 2)
if len(parts) == 3:
result[parts[2]] = int(parts[0])
except:
pass
return result
def format_size(b):
for u in ['B','KB','MB','GB','TB']:
if b < 1024: return f'{b:.1f} {u}'
b /= 1024
return f'{b:.1f} PB'
before = read_snapshot('${before_snap}')
after = read_snapshot('${after_snap}')
added = [p for p in after if p not in before]
modified = [p for p in after if p in before and after[p] != before[p]]
deleted = [p for p in before if p not in after]
total_size = sum(after.values())
lines = []
if not added and not modified and not deleted:
lines.append('Nessuna modifica rilevata')
else:
if added:
lines.append(f'**+{len(added)}** nuovi')
for f in added[:3]:
lines.append(f' ↳ {f} ({format_size(after[f])})')
if len(added) > 3:
lines.append(f' ↳ ...e altri {len(added)-3}')
if modified:
lines.append(f'**~{len(modified)}** modificati')
for f in modified[:3]:
lines.append(f' ↳ {f}')
if len(modified) > 3:
lines.append(f' ↳ ...e altri {len(modified)-3}')
if deleted:
lines.append(f'**-{len(deleted)}** rimossi')
for f in deleted[:3]:
lines.append(f' ↳ {f}')
if len(deleted) > 3:
lines.append(f' ↳ ...e altri {len(deleted)-3}')
lines.append(f'\\n**Totale locale:** {len(after)} file ({format_size(total_size)})')
print('\\\\n'.join(lines))
" 2>/dev/null || echo "Riepilogo non disponibile")
gotify_send \
"✅ Sync #${SYNC_COUNT} completata" \
"**Bucket:** ${S3_BUCKET}\\n**Modalità:** ${SYNC_MODE}\\n**Pianificazione:** ${schedule_info}\\n**Durata:** ${duration}s\\n\\n---\\n\\n${summary}"
else
# --- Sync fallita: notifica con priorità alta ---
gotify_send \
"❌ Sync fallita" \
"**Bucket:** ${S3_BUCKET}\\n**Modalità:** ${SYNC_MODE}\\n**Pianificazione:** ${schedule_info}\\n**Durata:** ${duration}s\\n**Errori totali:** ${ERROR_COUNT}\\n\\nControllare i log per dettagli." \
"8"
fi
}
# --- Validazione variabili obbligatorie ---
log_info "Avvio S3-to-Local Sync..."
write_status "starting" "Validazione configurazione..."
REQUIRED_VARS=("S3_ENDPOINT" "S3_ACCESS_KEY" "S3_SECRET_KEY" "S3_BUCKET")
for var in "${REQUIRED_VARS[@]}"; do
if [ -z "${!var:-}" ]; then
log_error "Variabile d'ambiente obbligatoria non impostata: $var"
write_status "error" "Variabile mancante: $var"
exit 1
fi
done
# --- Contatori ---
SYNC_COUNT=0
ERROR_COUNT=0
LAST_SYNC_TS="null"
# --- Impostazione permessi utente (se specificati) ---
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
log_info "File verranno creati con UID=$PUID, GID=$PGID"
# --- Configurazione rclone via environment (no file di config) ---
# Rclone supporta la configurazione tramite variabili d'ambiente
# con il prefisso RCLONE_CONFIG_<REMOTE>_<PARAMETRO>
export RCLONE_CONFIG_S3SOURCE_TYPE="s3"
export RCLONE_CONFIG_S3SOURCE_PROVIDER="Other"
export RCLONE_CONFIG_S3SOURCE_ENDPOINT="${S3_ENDPOINT}"
export RCLONE_CONFIG_S3SOURCE_ACCESS_KEY_ID="${S3_ACCESS_KEY}"
export RCLONE_CONFIG_S3SOURCE_SECRET_ACCESS_KEY="${S3_SECRET_KEY}"
export RCLONE_CONFIG_S3SOURCE_FORCE_PATH_STYLE="${S3_FORCE_PATH_STYLE:-true}"
# Regione (opzionale)
if [ -n "${S3_REGION:-}" ]; then
export RCLONE_CONFIG_S3SOURCE_REGION="${S3_REGION}"
fi
# Gestione SSL insicuro (per certificati self-signed in sviluppo)
if [ "${S3_INSECURE_SSL:-false}" = "true" ]; then
log_warn "SSL verification disabilitata - usare solo in sviluppo!"
export RCLONE_CONFIG_S3SOURCE_NO_CHECK_CERTIFICATE="true"
fi
# --- Costruzione path sorgente ---
# Se S3_PATH_PREFIX è specificato, sincronizza solo quella sottocartella
SOURCE_PATH="S3SOURCE:${S3_BUCKET}"
if [ -n "${S3_PATH_PREFIX:-}" ]; then
SOURCE_PATH="${SOURCE_PATH}/${S3_PATH_PREFIX}"
log_info "Sincronizzazione limitata al prefisso: ${S3_PATH_PREFIX}"
fi
# --- Parametri di sincronizzazione ---
SYNC_INTERVAL="${SYNC_INTERVAL:-300}"
SYNC_SCHEDULE="${SYNC_SCHEDULE:-}"
SYNC_MODE="${SYNC_MODE:-mirror}"
SYNC_TRANSFERS="${SYNC_TRANSFERS:-4}"
SYNC_BANDWIDTH="${SYNC_BANDWIDTH:-0}"
SYNC_LOG_LEVEL="${SYNC_LOG_LEVEL:-INFO}"
SYNC_ON_START="${SYNC_ON_START:-true}"
LOCAL_PATH="/data/local"
# --- Helper cron (script Python per il parsing delle espressioni) ---
CRON_HELPER="/app/web/cron_helper.py"
# --- Determina la modalità di scheduling ---
# Se SYNC_SCHEDULE è impostato, usa la pianificazione cron
# Altrimenti, usa SYNC_INTERVAL (comportamento classico)
if [ -n "${SYNC_SCHEDULE}" ]; then
SCHEDULE_MODE="cron"
# Valida l'espressione cron all'avvio
if ! python3 "${CRON_HELPER}" "${SYNC_SCHEDULE}" validate >/dev/null 2>&1; then
log_error "Espressione cron non valida: '${SYNC_SCHEDULE}'"
log_error "Formato: minuto ora giorno_mese mese giorno_settimana"
log_error "Esempi: '0 3 * * *' (ogni giorno alle 3:00), '*/30 * * * *' (ogni 30 min)"
write_status "error" "Espressione cron non valida: ${SYNC_SCHEDULE}"
exit 1
fi
SCHEDULE_HUMAN=$(python3 "${CRON_HELPER}" "${SYNC_SCHEDULE}" human)
log_info "Pianificazione: CRON - ${SCHEDULE_HUMAN} (${SYNC_SCHEDULE})"
else
SCHEDULE_MODE="interval"
log_info "Pianificazione: INTERVALLO - ogni ${SYNC_INTERVAL}s"
fi
# --- Determina il comando rclone in base alla modalità ---
case "${SYNC_MODE}" in
mirror)
# Mirror: replica esatta, cancella file locali non presenti nel bucket
RCLONE_CMD="sync"
log_info "Modalità: MIRROR (i file rimossi dal bucket verranno rimossi localmente)"
;;
copy)
# Copy: solo copia, non cancella nulla localmente
RCLONE_CMD="copy"
log_info "Modalità: COPY (i file locali extra non verranno rimossi)"
;;
*)
log_error "Modalità di sync non valida: ${SYNC_MODE} (valori ammessi: mirror, copy)"
write_status "error" "Modalità non valida: ${SYNC_MODE}"
exit 1
;;
esac
# --- Riepilogo configurazione ---
log_info "========================================="
log_info "Configurazione:"
log_info " Endpoint: ${S3_ENDPOINT}"
log_info " Bucket: ${S3_BUCKET}"
log_info " Prefisso: ${S3_PATH_PREFIX:-(nessuno, tutto il bucket)}"
log_info " Destinazione: ${LOCAL_PATH}"
log_info " Modalità: ${SYNC_MODE} (comando: rclone ${RCLONE_CMD})"
if [ "${SCHEDULE_MODE}" = "cron" ]; then
log_info " Pianificazione: ${SCHEDULE_HUMAN} (${SYNC_SCHEDULE})"
else
log_info " Intervallo: ${SYNC_INTERVAL}s"
fi
log_info " Sync all'avvio: ${SYNC_ON_START}"
log_info " Trasferimenti paralleli: ${SYNC_TRANSFERS}"
log_info " Limite banda: ${SYNC_BANDWIDTH:-nessuno}"
log_info " Log level: ${SYNC_LOG_LEVEL}"
if [ "${GOTIFY_ENABLED}" = "true" ]; then
log_info " Gotify: abilitato (${GOTIFY_URL}), priorità ${GOTIFY_PRIORITY}"
else
log_info " Gotify: disabilitato"
fi
log_info "========================================="
# --- Gestione segnali per shutdown pulito ---
RUNNING=true
trap 'log_info "Ricevuto segnale di stop, arresto..."; RUNNING=false' SIGTERM SIGINT
# --- Funzione di sincronizzazione ---
do_sync() {
log_info "Inizio sincronizzazione..."
write_status "syncing" "Sincronizzazione in corso..."
# Snapshot pre-sync per rilevare le modifiche
local before_snap="${STATE_DIR}/.snap_before"
local after_snap="${STATE_DIR}/.snap_after"
take_snapshot "${before_snap}"
# Timestamp di inizio per calcolare la durata
local start_ts
start_ts=$(date +%s)
# Costruisci gli argomenti rclone
RCLONE_ARGS=(
"${RCLONE_CMD}"
"${SOURCE_PATH}"
"${LOCAL_PATH}"
"--transfers" "${SYNC_TRANSFERS}"
"--log-level" "${SYNC_LOG_LEVEL}"
"--stats" "30s" # Mostra statistiche ogni 30s durante il trasferimento
"--stats-one-line" # Statistiche su una riga (più leggibile nei log)
"--no-update-modtime" # Non modifica i timestamp locali se il contenuto è uguale
"--log-file" "${LOG_FILE}" # Log anche su file per la dashboard
)
# Limite di banda (se specificato e diverso da 0)
if [ "${SYNC_BANDWIDTH}" != "0" ]; then
RCLONE_ARGS+=("--bwlimit" "${SYNC_BANDWIDTH}")
fi
# Esegui rclone
local end_ts duration
if rclone "${RCLONE_ARGS[@]}"; then
end_ts=$(date +%s)
duration=$((end_ts - start_ts))
log_info "Sincronizzazione completata con successo in ${duration}s"
# Aggiorna contatori
SYNC_COUNT=$((SYNC_COUNT + 1))
LAST_SYNC_TS="\"$(date -u '+%Y-%m-%dT%H:%M:%SZ')\""
# Snapshot post-sync e calcolo differenze
take_snapshot "${after_snap}"
update_recent_changes "${after_snap}" "${before_snap}"
# Notifica Gotify: unica notifica a fine sync con riepilogo
gotify_notify_sync_result "true" "${duration}" "${after_snap}" "${before_snap}"
# Aggiungi allo storico
append_history "true" "${duration}" "0" "0 B"
# Aggiorna proprietario dei file se necessario
chown -R "${PUID}:${PGID}" "${LOCAL_PATH}" 2>/dev/null || true
write_status "idle" "Sincronizzazione completata"
else
end_ts=$(date +%s)
duration=$((end_ts - start_ts))
log_error "Sincronizzazione fallita dopo ${duration}s"
ERROR_COUNT=$((ERROR_COUNT + 1))
# Notifica Gotify: unica notifica di errore (priorità alta)
gotify_notify_sync_result "false" "${duration}"
append_history "false" "${duration}" "0" "0 B"
write_status "error" "Sincronizzazione fallita"
fi
# Pulizia snapshot temporanei
rm -f "${before_snap}" "${after_snap}"
# Tronca log se troppo grande
truncate_log_if_needed
}
# --- Loop principale ---
# Esegui la prima sincronizzazione all'avvio (se abilitato)
if [ "${SYNC_ON_START}" = "true" ]; then
log_info "Sincronizzazione iniziale all'avvio..."
do_sync
else
log_info "Sync all'avvio disabilitata, in attesa della prossima pianificazione..."
write_status "waiting" "In attesa della prima esecuzione pianificata"
fi
# Funzione per l'attesa interrompibile di N secondi
wait_seconds() {
local total="$1"
local waited=0
while [ "$RUNNING" = true ] && [ "$waited" -lt "$total" ]; do
sleep 1
waited=$((waited + 1))
done
}
# Loop in base alla modalità di scheduling
if [ "${SCHEDULE_MODE}" = "cron" ]; then
# ===== MODALITÀ CRON =====
# Calcola i secondi fino alla prossima esecuzione cron e attende
while [ "$RUNNING" = true ]; do
# Calcola prossima esecuzione
NEXT_RUN=$(python3 "${CRON_HELPER}" "${SYNC_SCHEDULE}" next)
WAIT_SECS=$(python3 "${CRON_HELPER}" "${SYNC_SCHEDULE}" seconds)
log_info "Prossima esecuzione pianificata: ${NEXT_RUN} (tra ${WAIT_SECS}s)"
write_status "waiting" "Prossima sync: ${NEXT_RUN}"
# Attesa interrompibile
wait_seconds "${WAIT_SECS}"
# Esegui sync solo se siamo ancora in esecuzione
if [ "$RUNNING" = true ]; then
do_sync
fi
done
else
# ===== MODALITÀ INTERVALLO =====
# Comportamento classico: attende SYNC_INTERVAL secondi tra le sync
while [ "$RUNNING" = true ]; do
write_status "waiting" "Prossima sync tra ${SYNC_INTERVAL}s"
# Attesa interrompibile
wait_seconds "${SYNC_INTERVAL}"
# Esegui sync solo se siamo ancora in esecuzione
if [ "$RUNNING" = true ]; then
do_sync
fi
done
fi
write_status "idle" "Sync arrestato"
log_info "Sync arrestato correttamente. Arrivederci!"