#!/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}" </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 # Crea il payload JSON con corretta escape # Usa jq se disponibile, altrimenti fallback a printf con escape local payload if command -v jq &>/dev/null; then payload=$(jq -c -n \ --arg title "$title" \ --arg message "$message" \ --argjson priority "$priority" \ '{title: $title, message: $message, priority: $priority, extras: {"client::display": {contentType: "text/markdown"}}}') else # Fallback: escape manuale per i caratteri JSON title=$(printf '%s' "$title" | sed 's/\\/\\\\/g; s/"/\\"/g') message=$(printf '%s' "$message" | sed 's/\\/\\\\/g; s/"/\\"/g; s/$/, /g' | sed 's/, $//') payload="{\"title\":\"${title}\",\"message\":\"${message}\",\"priority\":${priority},\"extras\":{\"client::display\":{\"contentType\":\"text/markdown\"}}}" fi # Invia la notifica in background per non bloccare la sync # Cattura il risultato per il debug wget -q --timeout=10 --tries=2 -O /dev/null \ --post-data="${payload}" \ --header="Content-Type: application/json" \ "${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}" 2>/tmp/gotify_error.log & local pid=$! wait $pid 2>/dev/null if [ $? -eq 0 ]; then log_info "Notifica Gotify inviata: ${title}" else log_warn "Errore invio Gotify: $(cat /tmp/gotify_error.log 2>/dev/null || echo 'sconosciuto')" fi } 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__ 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!"