From d2080c936f9eac16cb3b958789b6744360623ab9 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Wed, 18 Mar 2026 13:49:16 +0100 Subject: [PATCH] ++ Primo Caricamento --- .dockerignore | 8 + .gitignore | 9 + Dockerfile | 52 +++ docker-compose.yml | 84 ++++ entrypoint.sh | 49 +++ sync.sh | 603 +++++++++++++++++++++++++ web/app.py | 212 +++++++++ web/cron_helper.py | 232 ++++++++++ web/templates/index.html | 925 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 2174 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 sync.sh create mode 100644 web/app.py create mode 100644 web/cron_helper.py create mode 100644 web/templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..085b07e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Non copiare nel container i file di configurazione e i dati +.env +.env.* +data/ +.git +.gitignore +README.md +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd71762 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Dati sincronizzati +data/ + +# File di ambiente con credenziali +.env + +# File di sistema +.DS_Store +*.swp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31115d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# ============================================================ +# Dockerfile - S3 to Local Sync + Web Dashboard +# ============================================================ +# Immagine leggera basata su Alpine con: +# - rclone: sincronizzazione da bucket S3-compatibili +# - Flask: dashboard web in tempo reale (stile Syncthing) +# - wget (incluso in BusyBox): notifiche push via Gotify +# ============================================================ + +FROM alpine:3.21 + +# Installa le dipendenze: +# - rclone: tool di sincronizzazione cloud storage +# - python3 + pip: per il web server Flask +# - tzdata: supporto timezone +# - ca-certificates: certificati SSL per connessioni HTTPS +# - tini: init process per gestione corretta dei segnali +# - bash: shell per gli script +# - findutils: find con supporto -printf per gli snapshot +RUN apk add --no-cache \ + rclone \ + python3 \ + py3-pip \ + tzdata \ + ca-certificates \ + tini \ + bash \ + findutils + +# Installa Flask (web framework leggero per la dashboard) +# --break-system-packages necessario su Alpine 3.21+ con Python 3.12+ +RUN pip3 install --no-cache-dir --break-system-packages flask + +# Crea le directory di lavoro +RUN mkdir -p /data/local /data/state /app + +# Copia l'applicazione web +COPY web/ /app/web/ + +# Copia gli script +COPY sync.sh /usr/local/bin/sync.sh +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/sync.sh /usr/local/bin/entrypoint.sh + +# Porta esposta per la dashboard web (configurabile via WEB_PORT) +EXPOSE 8080 + +# Usa tini come init per gestire correttamente i segnali (SIGTERM, etc.) +ENTRYPOINT ["/sbin/tini", "--"] + +# Avvia l'entrypoint che gestisce sia web che sync +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3ba0b12 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +# ============================================================ +# Docker Compose - S3 to Local Mirror + Web Dashboard +# ============================================================ +# Servizio che sincronizza un bucket S3-compatibile (RustFS, MinIO, etc.) +# verso una cartella locale montata come volume. +# Include una dashboard web in tempo reale (stile Syncthing). +# +# Uso: +# 1. Configurare il file .env con i parametri del bucket +# 2. docker compose up -d +# 3. Aprire http://localhost:8080 per la dashboard +# 4. I file appariranno nella cartella definita da LOCAL_SYNC_PATH +# ============================================================ + +services: + s3-sync: + build: + context: . + dockerfile: Dockerfile + container_name: s3-to-local-sync + restart: unless-stopped + + # Variabili d'ambiente passate al container + environment: + # --- S3 --- + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_ACCESS_KEY=${S3_ACCESS_KEY} + - S3_SECRET_KEY=${S3_SECRET_KEY} + - S3_BUCKET=${S3_BUCKET} + - S3_PATH_PREFIX=${S3_PATH_PREFIX:-} + - S3_REGION=${S3_REGION:-} + - S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE:-true} + - S3_INSECURE_SSL=${S3_INSECURE_SSL:-false} + # --- Sync / Pianificazione --- + - SYNC_INTERVAL=${SYNC_INTERVAL:-300} + - SYNC_SCHEDULE=${SYNC_SCHEDULE:-} + - SYNC_ON_START=${SYNC_ON_START:-true} + - SYNC_MODE=${SYNC_MODE:-mirror} + - SYNC_TRANSFERS=${SYNC_TRANSFERS:-4} + - SYNC_BANDWIDTH=${SYNC_BANDWIDTH:-0} + - SYNC_LOG_LEVEL=${SYNC_LOG_LEVEL:-INFO} + # --- Notifiche Gotify --- + - GOTIFY_ENABLED=${GOTIFY_ENABLED:-false} + - GOTIFY_URL=${GOTIFY_URL:-} + - GOTIFY_TOKEN=${GOTIFY_TOKEN:-} + - GOTIFY_PRIORITY=${GOTIFY_PRIORITY:-5} + # --- Web Dashboard --- + - WEB_PORT=${WEB_PORT:-8080} + - STATE_DIR=/data/state + # --- Sistema --- + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} + - TZ=${TZ:-Europe/Rome} + + # Volumi: + # - Cartella locale per i file sincronizzati + # - Volume per lo stato interno (persistente tra i restart) + volumes: + - ${LOCAL_SYNC_PATH:-./data}:/data/local + - sync-state:/data/state + + # Porta per la dashboard web + ports: + - "${WEB_PORT:-8080}:${WEB_PORT:-8080}" + + # Healthcheck: verifica che sia il sync che il web server siano attivi + healthcheck: + test: ["CMD-SHELL", "pgrep -f sync.sh && wget -q --spider http://localhost:${WEB_PORT:-8080}/ || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + # Limiti risorse (opzionali, decommentare se necessario) + # deploy: + # resources: + # limits: + # memory: 256M + # cpus: "0.5" + +# Volume per lo stato persistente della sync (storico, log, etc.) +volumes: + sync-state: + driver: local diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..003347c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# ============================================================ +# entrypoint.sh - Avvia sia il web server che lo script di sync +# ============================================================ +# Gestisce i due processi: +# 1. Flask web dashboard (background) +# 2. Script di sincronizzazione rclone (foreground) +# +# Lo shutdown pulito viene propagato a entrambi i processi. +# ============================================================ + +set -euo pipefail + +# --- Crea directory condivise --- +mkdir -p /data/state /data/local + +# --- Porta web (configurabile) --- +WEB_PORT="${WEB_PORT:-8080}" + +echo "[ENTRYPOINT] Avvio dashboard web su porta ${WEB_PORT}..." + +# Avvia il web server Flask in background +# - Usa python3 -u per output unbuffered (log immediati) +python3 -u /app/web/app.py & +WEB_PID=$! + +echo "[ENTRYPOINT] Dashboard avviata (PID: ${WEB_PID})" +echo "[ENTRYPOINT] Avvio sincronizzazione..." + +# --- Gestione shutdown: termina entrambi i processi --- +cleanup() { + echo "[ENTRYPOINT] Arresto in corso..." + # Invia SIGTERM al web server + kill "${WEB_PID}" 2>/dev/null || true + wait "${WEB_PID}" 2>/dev/null || true + echo "[ENTRYPOINT] Arresto completato." +} +trap cleanup SIGTERM SIGINT + +# Avvia lo script di sync in foreground +# Questo è il processo principale: se termina, il container si ferma +/usr/local/bin/sync.sh & +SYNC_PID=$! + +# Attendi che uno dei due processi termini +wait -n "${WEB_PID}" "${SYNC_PID}" 2>/dev/null || true + +# Se siamo qui, un processo è terminato: ferma l'altro +cleanup diff --git a/sync.sh b/sync.sh new file mode 100644 index 0000000..7c46772 --- /dev/null +++ b/sync.sh @@ -0,0 +1,603 @@ +#!/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 + + # 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__ +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!" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..252c7ad --- /dev/null +++ b/web/app.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# ============================================================ +# app.py - Dashboard Web per S3-to-Local Sync +# ============================================================ +# Server Flask leggero che espone una dashboard in tempo reale +# con Server-Sent Events (SSE) per lo streaming dei log e dello +# stato della sincronizzazione. Ispirato all'UI di Syncthing. +# ============================================================ + +import json +import os +import sys +import time +import threading +from datetime import datetime +from pathlib import Path +from flask import Flask, render_template, Response, jsonify, stream_with_context + +# Aggiungi la directory web al path per importare cron_helper +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# ============================================================ +# Configurazione +# ============================================================ + +# Directory condivisa con sync.sh per lo scambio di stato +STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/state")) + +# Porta del web server (configurabile da .env) +WEB_PORT = int(os.environ.get("WEB_PORT", 8080)) + +# File di stato scritti da sync.sh +STATUS_FILE = STATE_DIR / "status.json" # Stato corrente della sync +HISTORY_FILE = STATE_DIR / "history.json" # Storico delle sincronizzazioni +LOG_FILE = STATE_DIR / "sync.log" # Log in tempo reale +CHANGES_FILE = STATE_DIR / "recent_changes.json" # Ultime modifiche ai file + +app = Flask(__name__) + + +# ============================================================ +# Funzioni di utilità +# ============================================================ + +def read_json_safe(filepath, default=None): + """Legge un file JSON in modo sicuro, ritorna default se non esiste o è corrotto.""" + try: + if filepath.exists(): + text = filepath.read_text().strip() + if text: + return json.loads(text) + except (json.JSONDecodeError, OSError): + pass + return default if default is not None else {} + + +def get_folder_stats(path="/data/local"): + """Calcola statistiche sulla cartella sincronizzata (numero file, dimensione totale).""" + total_size = 0 + file_count = 0 + dir_count = 0 + try: + for entry in Path(path).rglob("*"): + if entry.is_file(): + file_count += 1 + total_size += entry.stat().st_size + elif entry.is_dir(): + dir_count += 1 + except OSError: + pass + return { + "file_count": file_count, + "dir_count": dir_count, + "total_size": total_size, + "total_size_human": format_size(total_size), + } + + +def format_size(size_bytes): + """Converte bytes in formato leggibile (es. 1.5 GB).""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + + +# ============================================================ +# Routes +# ============================================================ + +@app.route("/") +def index(): + """Pagina principale della dashboard.""" + # Passa la configurazione al template per il riepilogo + # Determina la modalità di pianificazione + sync_schedule = os.environ.get("SYNC_SCHEDULE", "") + sync_interval = int(os.environ.get("SYNC_INTERVAL", 300)) + + if sync_schedule: + # Modalità cron: mostra l'espressione e la descrizione leggibile + from cron_helper import human_readable + schedule_display = human_readable(sync_schedule) + schedule_mode = "cron" + else: + schedule_display = f"Ogni {sync_interval}s" + schedule_mode = "interval" + + config = { + "endpoint": os.environ.get("S3_ENDPOINT", "N/A"), + "bucket": os.environ.get("S3_BUCKET", "N/A"), + "prefix": os.environ.get("S3_PATH_PREFIX", "") or "(tutto il bucket)", + "sync_mode": os.environ.get("SYNC_MODE", "mirror"), + "sync_interval": sync_interval, + "sync_schedule": sync_schedule, + "schedule_mode": schedule_mode, + "schedule_display": schedule_display, + "sync_on_start": os.environ.get("SYNC_ON_START", "true"), + "transfers": os.environ.get("SYNC_TRANSFERS", "4"), + "bandwidth": os.environ.get("SYNC_BANDWIDTH", "0") or "illimitata", + } + return render_template("index.html", config=config) + + +@app.route("/api/status") +def api_status(): + """API: restituisce lo stato corrente della sincronizzazione.""" + status = read_json_safe(STATUS_FILE, { + "state": "starting", + "last_sync": None, + "next_sync": None, + "message": "In avvio...", + }) + # Aggiungi statistiche della cartella locale + status["folder_stats"] = get_folder_stats() + return jsonify(status) + + +@app.route("/api/history") +def api_history(): + """API: restituisce lo storico delle ultime sincronizzazioni.""" + history = read_json_safe(HISTORY_FILE, []) + return jsonify(history) + + +@app.route("/api/changes") +def api_changes(): + """API: restituisce le ultime modifiche ai file.""" + changes = read_json_safe(CHANGES_FILE, []) + return jsonify(changes) + + +@app.route("/api/stream") +def api_stream(): + """SSE: stream in tempo reale dei log della sincronizzazione.""" + def generate(): + """Generatore SSE: legge il file di log e invia nuove righe al client.""" + # Inizia dalla fine del file se esiste + try: + if LOG_FILE.exists(): + # Invia le ultime 50 righe come contesto iniziale + with open(LOG_FILE, "r") as f: + lines = f.readlines() + for line in lines[-50:]: + yield f"data: {json.dumps({'type': 'log', 'message': line.strip()})}\n\n" + + # Poi segui il file (tail -f style) + with open(LOG_FILE, "r") as f: + f.seek(0, 2) # Vai alla fine del file + while True: + line = f.readline() + if line: + yield f"data: {json.dumps({'type': 'log', 'message': line.strip()})}\n\n" + else: + # Nessuna nuova riga: invia heartbeat per mantenere la connessione + yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n" + time.sleep(1) + else: + # File non ancora creato: attendi + while not LOG_FILE.exists(): + yield f"data: {json.dumps({'type': 'log', 'message': 'In attesa del primo avvio sync...'})}\n\n" + time.sleep(2) + # Ricomincia leggendo il file appena creato + yield from generate() + except GeneratorExit: + pass + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # Disabilita buffering nginx (se presente) + }, + ) + + +# ============================================================ +# Avvio server +# ============================================================ + +if __name__ == "__main__": + # Assicura che la directory di stato esista + STATE_DIR.mkdir(parents=True, exist_ok=True) + + app.run( + host="0.0.0.0", + port=WEB_PORT, + debug=False, + # threaded=True per gestire più client SSE contemporaneamente + threaded=True, + ) diff --git a/web/cron_helper.py b/web/cron_helper.py new file mode 100644 index 0000000..d99e9ec --- /dev/null +++ b/web/cron_helper.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# ============================================================ +# cron_helper.py - Parser di espressioni cron minimale +# ============================================================ +# Calcola i secondi mancanti alla prossima esecuzione basata +# su un'espressione cron standard a 5 campi. +# +# Formato: minuto ora giorno_mese mese giorno_settimana +# +# Supporta: +# * = ogni valore +# */N = ogni N unità (step) +# N = valore esatto +# N,M = lista di valori +# N-M = range di valori +# N-M/S = range con step +# +# Uso: +# python3 cron_helper.py "0 3 * * *" → secondi fino alle 3:00 +# python3 cron_helper.py "*/30 * * * *" next → timestamp prossima esecuzione +# python3 cron_helper.py "0 3 * * *" human → descrizione leggibile +# ============================================================ + +import sys +from datetime import datetime, timedelta + + +def parse_cron_field(field, min_val, max_val): + """ + Parsa un singolo campo dell'espressione cron e ritorna + l'insieme dei valori validi. + + Esempi: + "*" → {0,1,2,...,max_val} + "*/15" → {0,15,30,45} (per i minuti) + "1,15" → {1,15} + "1-5" → {1,2,3,4,5} + "1-10/2" → {1,3,5,7,9} + """ + values = set() + + for part in field.split(","): + if "/" in part: + # Gestione step: */N o range/N + range_part, step = part.split("/", 1) + step = int(step) + + if range_part == "*": + start, end = min_val, max_val + elif "-" in range_part: + start, end = map(int, range_part.split("-", 1)) + else: + start, end = int(range_part), max_val + + values.update(range(start, end + 1, step)) + + elif "-" in part: + # Range: N-M + start, end = map(int, part.split("-", 1)) + values.update(range(start, end + 1)) + + elif part == "*": + # Wildcard: tutti i valori + values.update(range(min_val, max_val + 1)) + + else: + # Valore singolo + values.add(int(part)) + + return values + + +def parse_cron_expression(expression): + """ + Parsa un'espressione cron a 5 campi e ritorna i set di valori validi. + + Campi: minuto(0-59) ora(0-23) giorno_mese(1-31) mese(1-12) giorno_settimana(0-6, 0=dom) + """ + parts = expression.strip().split() + if len(parts) != 5: + raise ValueError( + f"Espressione cron non valida: '{expression}'. " + f"Servono 5 campi: minuto ora giorno_mese mese giorno_settimana" + ) + + minutes = parse_cron_field(parts[0], 0, 59) + hours = parse_cron_field(parts[1], 0, 23) + days = parse_cron_field(parts[2], 1, 31) + months = parse_cron_field(parts[3], 1, 12) + weekdays = parse_cron_field(parts[4], 0, 6) + + return minutes, hours, days, months, weekdays + + +def next_cron_time(expression, now=None): + """ + Calcola il prossimo istante che corrisponde all'espressione cron. + Ritorna un oggetto datetime. + + Cerca fino a 366 giorni nel futuro (copre un anno intero + margine). + """ + if now is None: + now = datetime.now() + + minutes, hours, days, months, weekdays = parse_cron_expression(expression) + + # Parti dal minuto successivo (non eseguire "adesso") + candidate = now.replace(second=0, microsecond=0) + timedelta(minutes=1) + + # Cerca nei prossimi 366 giorni (massimo ~527040 minuti) + max_iterations = 366 * 24 * 60 + for _ in range(max_iterations): + if (candidate.month in months and + candidate.day in days and + candidate.weekday() in _convert_weekdays(weekdays) and + candidate.hour in hours and + candidate.minute in minutes): + return candidate + candidate += timedelta(minutes=1) + + raise ValueError(f"Nessuna esecuzione trovata per '{expression}' nei prossimi 366 giorni") + + +def _convert_weekdays(cron_weekdays): + """ + Converte i giorni della settimana dal formato cron (0=domenica) + al formato Python (0=lunedì). + + Cron: 0=dom, 1=lun, 2=mar, 3=mer, 4=gio, 5=ven, 6=sab + Python: 0=lun, 1=mar, 2=mer, 3=gio, 4=ven, 5=sab, 6=dom + """ + mapping = {0: 6, 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5} + return {mapping[d] for d in cron_weekdays} + + +def seconds_until_next(expression): + """Calcola i secondi mancanti alla prossima esecuzione cron.""" + now = datetime.now() + next_time = next_cron_time(expression, now) + delta = (next_time - now).total_seconds() + return max(1, int(delta)) + + +def human_readable(expression): + """ + Genera una descrizione leggibile in italiano dell'espressione cron. + Copre i casi più comuni; per espressioni complesse ritorna il formato raw. + """ + parts = expression.strip().split() + if len(parts) != 5: + return expression + + m, h, dom, mon, dow = parts + + # Casi comuni + if m == "*" and h == "*" and dom == "*" and mon == "*" and dow == "*": + return "Ogni minuto" + + if m.startswith("*/") and h == "*" and dom == "*" and mon == "*" and dow == "*": + mins = m.split("/")[1] + return f"Ogni {mins} minuti" + + if h.startswith("*/") and dom == "*" and mon == "*" and dow == "*": + hrs = h.split("/")[1] + if m == "0": + return f"Ogni {hrs} ore" + return f"Al minuto {m}, ogni {hrs} ore" + + if dom == "*" and mon == "*" and dow == "*": + if m.isdigit() and h.isdigit(): + return f"Ogni giorno alle {h.zfill(2)}:{m.zfill(2)}" + if m.isdigit() and "," in h: + ore = h.replace(",", ", ") + return f"Alle ore {ore}, al minuto {m}" + + if dom == "*" and mon == "*" and dow != "*": + giorni_map = {"0": "dom", "1": "lun", "2": "mar", "3": "mer", + "4": "gio", "5": "ven", "6": "sab"} + if "," in dow: + giorni = ", ".join(giorni_map.get(d.strip(), d) for d in dow.split(",")) + elif dow in giorni_map: + giorni = giorni_map[dow] + else: + giorni = dow + if m.isdigit() and h.isdigit(): + return f"Ogni {giorni} alle {h.zfill(2)}:{m.zfill(2)}" + + if dom != "*" and mon == "*" and dow == "*": + if m.isdigit() and h.isdigit(): + return f"Il giorno {dom} di ogni mese alle {h.zfill(2)}:{m.zfill(2)}" + + return f"Cron: {expression}" + + +# ============================================================ +# CLI: usato da sync.sh per calcolare i tempi +# ============================================================ + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Uso: cron_helper.py [seconds|next|human|validate]", file=sys.stderr) + sys.exit(1) + + expr = sys.argv[1] + mode = sys.argv[2] if len(sys.argv) > 2 else "seconds" + + try: + if mode == "seconds": + # Ritorna i secondi alla prossima esecuzione + print(seconds_until_next(expr)) + + elif mode == "next": + # Ritorna il timestamp della prossima esecuzione + next_time = next_cron_time(expr) + print(next_time.strftime("%Y-%m-%d %H:%M:%S")) + + elif mode == "human": + # Ritorna una descrizione leggibile + print(human_readable(expr)) + + elif mode == "validate": + # Valida l'espressione (exit 0 se OK, exit 1 se errore) + parse_cron_expression(expr) + print("OK") + + else: + print(f"Modalità sconosciuta: {mode}", file=sys.stderr) + sys.exit(1) + + except ValueError as e: + print(f"ERRORE: {e}", file=sys.stderr) + sys.exit(1) diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..234da43 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,925 @@ + + + + + + S3 Sync Dashboard + + + + + +
+
+ + + + Avvio... + +
+
+ + Connesso +
+
+ +
+ + +
+ +
+
+ Ultima Sincronizzazione +
+
+
--:--
+
In attesa...
+
+ + +
+
+ Prossima Sincronizzazione +
+
+
--:--
+
+
+ + +
+
+ File Locali +
📁
+
+
--
+
Calcolo...
+
+ + +
+
+ Sync Completate +
+
+
0
+
0 errori
+
+
+ + +
+

⚙ Configurazione Attiva

+
+
+ Endpoint + {{ config.endpoint }} +
+
+ Bucket + {{ config.bucket }} +
+
+ Prefisso + {{ config.prefix }} +
+
+ Modalità + {{ config.sync_mode }} +
+
+ Pianificazione + {{ config.schedule_display }}{% if config.schedule_mode == 'cron' %} ({{ config.sync_schedule }}){% endif %} +
+
+ Sync all'avvio + {{ config.sync_on_start }} +
+
+ Trasferimenti + {{ config.transfers }} paralleli +
+
+ Banda + {{ config.bandwidth }} +
+
+
+ + +
+ +
+
+

📜 Log in Tempo Reale

+
+ + +
+
+
+
+
📄
+ In attesa dei log... +
+
+
+ + +
+
+

🕒 Modifiche Recenti

+ 0 modifiche +
+
+
+
📁
+ Nessuna modifica registrata +
+
+
+
+ + +
+

📊 Storico Sincronizzazioni

+
+
+
📋
+ Nessuna sincronizzazione completata +
+
+
+ +
+ + + +