diff --git a/backend/app/services/recordings/notify_expiring.rb b/backend/app/services/recordings/notify_expiring.rb index f6f18aa..c872505 100644 --- a/backend/app/services/recordings/notify_expiring.rb +++ b/backend/app/services/recordings/notify_expiring.rb @@ -13,7 +13,7 @@ module Recordings return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now recipients.each do |user| - Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now + deliver_expiring_soon(user) end @recording.update!(expiry_warning_sent_at: Time.current) @@ -22,6 +22,12 @@ module Recordings private + def deliver_expiring_soon(user) + Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now + rescue EOFError => e + Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}") + end + def recipients club = @recording.team.club owner = club.owner diff --git a/backend/app/services/recordings/notify_ready.rb b/backend/app/services/recordings/notify_ready.rb index 50718b8..4ad26cf 100644 --- a/backend/app/services/recordings/notify_ready.rb +++ b/backend/app/services/recordings/notify_ready.rb @@ -9,7 +9,7 @@ module Recordings return @recording unless @recording.ready? recipients.each do |user| - Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now + deliver_replay_ready(user) end @recording.update!(ready_notified_at: Time.current) @@ -18,6 +18,13 @@ module Recordings private + def deliver_replay_ready(user) + Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now + rescue EOFError => e + # Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita. + Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}") + end + def recipients club = @recording.team.club owner = club.owner diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb index e8cd684..aa805eb 100644 --- a/backend/config/environments/production.rb +++ b/backend/config/environments/production.rb @@ -54,13 +54,18 @@ Rails.application.configure do if ENV["SMTP_ADDRESS"].present? config.action_mailer.delivery_method = :smtp + smtp_port = ENV.fetch("SMTP_PORT", 587).to_i + smtp_ssl = ENV.fetch("SMTP_SSL", smtp_port == 465 ? "true" : "false") == "true" config.action_mailer.smtp_settings = { address: ENV["SMTP_ADDRESS"], - port: ENV.fetch("SMTP_PORT", 587).to_i, + port: smtp_port, user_name: ENV["SMTP_USERNAME"], password: ENV["SMTP_PASSWORD"], authentication: ENV.fetch("SMTP_AUTH", "plain"), - enable_starttls_auto: ENV.fetch("SMTP_STARTTLS", "true") == "true" + ssl: smtp_ssl, + enable_starttls_auto: !smtp_ssl && ENV.fetch("SMTP_STARTTLS", "true") == "true", + open_timeout: ENV.fetch("SMTP_OPEN_TIMEOUT", "15").to_i, + read_timeout: ENV.fetch("SMTP_READ_TIMEOUT", "60").to_i } end config.i18n.fallbacks = true diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb index 2e7ebde..ab60d88 100644 --- a/backend/config/initializers/match_live_tv.rb +++ b/backend/config/initializers/match_live_tv.rb @@ -90,7 +90,7 @@ module MatchLiveTv end def mail_from - ENV.fetch("MAILER_FROM", "Match Live TV ") + ENV.fetch("MAILER_FROM", "Match Live TV ") end def password_reset_expiry_hours diff --git a/docs/OPS_MONITORING.md b/docs/OPS_MONITORING.md index 7b28772..558630d 100644 --- a/docs/OPS_MONITORING.md +++ b/docs/OPS_MONITORING.md @@ -14,25 +14,63 @@ Sistema integrato per health check continui, analisi log, dashboard incidenti e | `/admin/ops` | Dashboard incidenti | | `GET /up/deep` | Health check esteso (JSON) | -## Setup notifiche ntfy (telefono) +## Setup notifiche ntfy self-hosted (telefono) -1. Installa l'app [ntfy](https://ntfy.sh) su Android/iOS -2. Scegli un topic segreto (es. `matchlivetv-ops-eminux-a8f3b2`) -3. Iscriviti al topic nell'app -4. Sul server, in `infra/.env`: +Le push **ops** (disco pieno, Sidekiq, servizi giù) non passano da Firebase: Rails invia un POST al server **ntfy** incluso in `docker-compose.prod.yml`. + +### 1. Server (Docker) ```bash -OPS_NTFY_URL=https://ntfy.sh/matchlivetv-ops-eminux-a8f3b2 -OPS_NOTIFY_SEVERITIES=critical +cd /opt/matchlivetv/infra +docker compose -f docker-compose.prod.yml --env-file .env up -d ntfy +``` + +Servizio in ascolto su **LAN** `http://192.168.1.146:8090` (non esporre 8090 su Internet). + +### 2. HTTPS (Nginx Proxy Manager) + +Crea un **Proxy Host** (consigliato sottodominio dedicato): + +| Campo | Valore | +|-------|--------| +| Domain | `ntfy.matchlivetv.it` | +| Forward | `http://192.168.1.146:8090` | +| WebSockets | **ON** | +| SSL | Let's Encrypt | + +In `infra/.env`: + +```bash +NTFY_PUBLIC_URL=https://ntfy.matchlivetv.it +OPS_NTFY_TOPIC=matchlivetv-ops-eminux-a8f3b2 # topic segreto, a piacere +OPS_NTFY_URL=http://ntfy:80/matchlivetv-ops-eminux-a8f3b2 +OPS_NOTIFY_SEVERITIES=critical,warning OPS_NOTIFY_COOLDOWN_MINUTES=30 ``` -5. Test: +`OPS_NTFY_URL` usa il nome Docker `ntfy` (rete interna). Il telefono usa invece `NTFY_PUBLIC_URL`. + +### 3. App sul telefono + +1. Installa [ntfy](https://ntfy.sh) (Android/iOS) +2. **Impostazioni → Default server** (o «Usa altro server») → `https://ntfy.matchlivetv.it` + In casa, in alternativa: `http://192.168.1.146:8090` +3. **+** → iscriviti al topic `OPS_NTFY_TOPIC` (es. `matchlivetv-ops-eminux-a8f3b2`) +4. Abilita notifiche per quel topic + +### 4. Test ```bash docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails ops:test_notify ``` +Dovresti ricevere una push «Test notifica ops» sul telefono. + +### Sicurezza + +- Il topic è la «password»: usa un nome lungo e casuale, non pubblicarlo +- Opzionale: abilita auth su ntfy (`OPS_NTFY_TOKEN` + utente in `user.db`) per topic protetti + ## Variabili ambiente Vedi [`infra/.env.production.example`](../infra/.env.production.example). diff --git a/infra/.env.example b/infra/.env.example index 5178d01..c0d53e5 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -12,7 +12,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key PRIVACY_CONTROLLER_NAME=Emiliano Frascaro PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI) PRIVACY_CONTACT_EMAIL=privacy@matchlive.it -MAILER_FROM=Match Live TV +MAILER_FROM=Match Live TV PASSWORD_RESET_EXPIRY_HOURS=2 # Google Analytics 4 (opzionale — caricato solo dopo consenso cookie «Accetta tutti») diff --git a/infra/.env.production.example b/infra/.env.production.example index 33af9e0..215c0f4 100644 --- a/infra/.env.production.example +++ b/infra/.env.production.example @@ -42,13 +42,15 @@ PRIVACY_CONTACT_EMAIL=privacy@matchlive.it PRIVACY_CONTROLLER_VAT= # Email transazionali (reset password, inviti) -MAILER_FROM=Match Live TV -SMTP_ADDRESS= -SMTP_PORT=587 -SMTP_USERNAME= +MAILER_FROM=Match Live TV +# Aruba: smtps.aruba.it porta 465 con SSL (non STARTTLS su 587) +SMTP_ADDRESS=smtps.aruba.it +SMTP_PORT=465 +SMTP_USERNAME=noreply@matchlivetv.it SMTP_PASSWORD= SMTP_AUTH=plain -SMTP_STARTTLS=true +SMTP_SSL=true +SMTP_STARTTLS=false PASSWORD_RESET_EXPIRY_HOURS=2 # Disco video dedicato (recordings MediaMTX + dati Garage). Montare es. 800GB su /media/videos @@ -63,12 +65,16 @@ REPLAY_STORAGE_ACCESS_KEY_ID= REPLAY_STORAGE_SECRET_ACCESS_KEY= REPLAY_STORAGE_FORCE_PATH_STYLE=true -# Ops monitoring — notifiche push (ntfy) e health check -# App ntfy: https://ntfy.sh — iscriviti al topic e imposta OPS_NTFY_URL -OPS_NTFY_URL=https://ntfy.sh/matchlivetv-ops-YOUR_SECRET_TOPIC +# Ops monitoring — notifiche push (ntfy self-hosted) e health check +# 1) Avvia servizio: docker compose up -d ntfy +# 2) NPM: proxy HTTPS → http://192.168.1.146:8090 (es. ntfy.matchlivetv.it, WebSockets ON) +# 3) App ntfy sul telefono: server custom = NTFY_PUBLIC_URL, iscriviti a OPS_NTFY_TOPIC +NTFY_PUBLIC_URL=https://ntfy.matchlivetv.it +# Topic segreto: stesso nome in app ntfy (subscribe) e in OPS_NTFY_URL +OPS_NTFY_URL=http://ntfy:80/matchlivetv-ops-YOUR_SECRET_SUFFIX OPS_NTFY_TOKEN= OPS_ALERT_EMAIL= -OPS_NOTIFY_SEVERITIES=critical +OPS_NOTIFY_SEVERITIES=critical,warning OPS_NOTIFY_COOLDOWN_MINUTES=30 OPS_HEALTH_INTERVAL_SECS=180 OPS_HEALTH_TOKEN= diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index f15c20b..e6ad5d7 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -37,7 +37,7 @@ services: volumes: - ./mediamtx.yml:/mediamtx.yml:ro - ./slates:/slates:ro - - recordings:/recordings + - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings command: /mediamtx.yml # Immagine distroless: niente shell/wget/curl; API :9997 non esposta sull'host healthcheck: @@ -77,13 +77,14 @@ services: PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro} PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)} PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-} - MAILER_FROM: ${MAILER_FROM:-Match Live TV } + MAILER_FROM: ${MAILER_FROM:-Match Live TV } SMTP_ADDRESS: ${SMTP_ADDRESS:-} - SMTP_PORT: ${SMTP_PORT:-587} + SMTP_PORT: ${SMTP_PORT:-465} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_AUTH: ${SMTP_AUTH:-plain} - SMTP_STARTTLS: ${SMTP_STARTTLS:-true} + SMTP_SSL: ${SMTP_SSL:-true} + SMTP_STARTTLS: ${SMTP_STARTTLS:-false} RAILS_LOG_TO_STDOUT: "true" RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} @@ -102,6 +103,20 @@ services: REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} GOOGLE_ANALYTICS_MEASUREMENT_ID: ${GOOGLE_ANALYTICS_MEASUREMENT_ID:-} + OPS_NTFY_URL: ${OPS_NTFY_URL:-} + OPS_NTFY_TOKEN: ${OPS_NTFY_TOKEN:-} + OPS_ALERT_EMAIL: ${OPS_ALERT_EMAIL:-} + OPS_NOTIFY_SEVERITIES: ${OPS_NOTIFY_SEVERITIES:-critical,warning} + OPS_NOTIFY_COOLDOWN_MINUTES: ${OPS_NOTIFY_COOLDOWN_MINUTES:-30} + OPS_HEALTH_INTERVAL_SECS: ${OPS_HEALTH_INTERVAL_SECS:-180} + OPS_HEALTH_TOKEN: ${OPS_HEALTH_TOKEN:-} + OPS_DISK_WARN_PERCENT: ${OPS_DISK_WARN_PERCENT:-80} + OPS_DISK_CRIT_PERCENT: ${OPS_DISK_CRIT_PERCENT:-90} + OPS_RECORDINGS_WARN_GB: ${OPS_RECORDINGS_WARN_GB:-20} + OPS_RECORDINGS_CRIT_GB: ${OPS_RECORDINGS_CRIT_GB:-50} + OPS_SIDEKIQ_STALE_SECS: ${OPS_SIDEKIQ_STALE_SECS:-300} + OPS_LOG_SUBSCRIBER: ${OPS_LOG_SUBSCRIBER:-false} + SENTRY_DSN: ${SENTRY_DSN:-} ports: - "3000:3000" # Solo LAN — NPM proxy HTTPS depends_on: @@ -114,7 +129,7 @@ services: garage: condition: service_started volumes: - - recordings:/recordings + - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings - rails_storage:/rails/storage healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] @@ -148,13 +163,14 @@ services: MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935 HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} - MAILER_FROM: ${MAILER_FROM:-Match Live TV } + MAILER_FROM: ${MAILER_FROM:-Match Live TV } SMTP_ADDRESS: ${SMTP_ADDRESS:-} - SMTP_PORT: ${SMTP_PORT:-587} + SMTP_PORT: ${SMTP_PORT:-465} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_AUTH: ${SMTP_AUTH:-plain} - SMTP_STARTTLS: ${SMTP_STARTTLS:-true} + SMTP_SSL: ${SMTP_SSL:-true} + SMTP_STARTTLS: ${SMTP_STARTTLS:-false} RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} RECORDINGS_PATH: /recordings REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} @@ -163,13 +179,29 @@ services: REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} + OPS_NTFY_URL: ${OPS_NTFY_URL:-} + OPS_NTFY_TOKEN: ${OPS_NTFY_TOKEN:-} + OPS_ALERT_EMAIL: ${OPS_ALERT_EMAIL:-} + OPS_NOTIFY_SEVERITIES: ${OPS_NOTIFY_SEVERITIES:-critical,warning} + OPS_NOTIFY_COOLDOWN_MINUTES: ${OPS_NOTIFY_COOLDOWN_MINUTES:-30} + OPS_HEALTH_INTERVAL_SECS: ${OPS_HEALTH_INTERVAL_SECS:-180} + OPS_HEALTH_TOKEN: ${OPS_HEALTH_TOKEN:-} + OPS_DISK_WARN_PERCENT: ${OPS_DISK_WARN_PERCENT:-80} + OPS_DISK_CRIT_PERCENT: ${OPS_DISK_CRIT_PERCENT:-90} + OPS_RECORDINGS_WARN_GB: ${OPS_RECORDINGS_WARN_GB:-20} + OPS_RECORDINGS_CRIT_GB: ${OPS_RECORDINGS_CRIT_GB:-50} + OPS_SIDEKIQ_STALE_SECS: ${OPS_SIDEKIQ_STALE_SECS:-300} + OPS_LOG_SUBSCRIBER: ${OPS_LOG_SUBSCRIBER:-false} + SENTRY_DSN: ${SENTRY_DSN:-} depends_on: rails: condition: service_healthy garage: condition: service_started + ntfy: + condition: service_started volumes: - - recordings:/recordings + - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings - rails_storage:/rails/storage garage: @@ -177,15 +209,31 @@ services: restart: unless-stopped # API S3 solo rete Docker (rails/sidekiq → http://garage:3900). Non pubblicare 3900 su WAN. volumes: - - garage_meta:/var/lib/garage/meta - - garage_data:/var/lib/garage/data + - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/garage/meta:/var/lib/garage/meta + - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/garage/data:/var/lib/garage/data - ./garage/garage.prod.toml:/etc/garage.toml:ro command: ["/garage", "-c", "/etc/garage.toml", "server"] + ntfy: + image: binwiederhier/ntfy:latest + restart: unless-stopped + # Solo LAN :8090 — esporre su HTTPS via NPM (es. ntfy.matchlivetv.it). Non aprire 8090 su WAN. + command: serve + environment: + NTFY_BASE_URL: ${NTFY_PUBLIC_URL:-http://127.0.0.1:8090} + volumes: + - ntfy_data:/var/lib/ntfy + - ./ntfy/server.yml:/etc/ntfy/server.yml:ro + ports: + - "8090:80" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/v1/health"] + interval: 30s + timeout: 5s + retries: 3 + volumes: postgres_data: redis_data: - recordings: rails_storage: - garage_meta: - garage_data: + ntfy_data: diff --git a/infra/ntfy/server.yml b/infra/ntfy/server.yml new file mode 100644 index 0000000..0da4583 --- /dev/null +++ b/infra/ntfy/server.yml @@ -0,0 +1,9 @@ +# ntfy self-hosted — Match Live TV (ops alerts) +# base-url impostato via NTFY_BASE_URL in docker-compose (NTFY_PUBLIC_URL in .env) +# Docs: https://docs.ntfy.sh/config/ + +behind-proxy: true +cache-file: "/var/lib/ntfy/cache.db" +attachment-cache-dir: "/var/lib/ntfy/attachments" +message-size-limit: "4k" +visitor-subscription-limit: 10 diff --git a/infra/scripts/ensure_garage_prod_config.sh b/infra/scripts/ensure_garage_prod_config.sh index 8e0564e..7196742 100755 --- a/infra/scripts/ensure_garage_prod_config.sh +++ b/infra/scripts/ensure_garage_prod_config.sh @@ -6,6 +6,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" DEST="${ROOT}/garage/garage.prod.toml" TEMPLATE="${ROOT}/garage/garage.prod.toml.example" +if [ -d "$DEST" ]; then + echo "ERRORE: $DEST è una directory (bind mount Docker senza file sorgente)." + echo "Rimuovi la directory e rilancia questo script." + exit 1 +fi + if [ -f "$DEST" ]; then echo "Config Garage produzione già presente: $DEST" exit 0 diff --git a/infra/scripts/garage_set_capacity.sh b/infra/scripts/garage_set_capacity.sh index aa47db4..bf4bb5c 100755 --- a/infra/scripts/garage_set_capacity.sh +++ b/infra/scripts/garage_set_capacity.sh @@ -8,9 +8,14 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" ENV_FILE="${ENV_FILE:-${ROOT}/.env}" GARAGE_CONTAINER="${GARAGE_CONTAINER:-}" +VIDEOS_ROOT="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}" if [ -z "$CAPACITY" ]; then - avail="$(df -BG "${ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)" + capacity_df="${VIDEOS_ROOT}" + if [ ! -d "$capacity_df" ]; then + capacity_df="${ROOT}" + fi + avail="$(df -BG "${capacity_df}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)" if [ "$avail" -lt 1 ]; then avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" fi diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh index eb4cf0a..bb9d159 100755 --- a/infra/scripts/install_production_cron.sh +++ b/infra/scripts/install_production_cron.sh @@ -14,14 +14,14 @@ mkdir -p "$LOG_DIR" MARKER="# matchlivetv-replay-cron" CRON_BLOCK="${MARKER} -30 * * * * ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 -0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 -0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 -*/10 * * * * ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 +30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 +0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 +0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 +*/10 * * * * mkdir -p ${LOG_DIR} && ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 " existing="$(crontab -l 2>/dev/null || true)" -filtered="$(echo "$existing" | grep -v "$MARKER" | grep -v 'run_rails_task\.sh' || true)" +filtered="$(echo "$existing" | grep -v "$MARKER" | grep -v 'run_rails_task\.sh' | grep -v 'scan_ops_logs\.sh' || true)" if echo "$existing" | grep -q "$MARKER"; then echo "Crontab replay già presente, aggiorno..." fi diff --git a/infra/scripts/migrate_videos_storage.sh b/infra/scripts/migrate_videos_storage.sh new file mode 100755 index 0000000..2c13223 --- /dev/null +++ b/infra/scripts/migrate_videos_storage.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Migra recordings + Garage da volumi Docker al disco video (es. /media/videos/matchlivetv). +# Uso: sudo bash scripts/migrate_videos_storage.sh +# Richiede: docker compose prod fermo o in fase di stop; disco montato su MATCHLIVETV_VIDEOS_ROOT. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +VIDEOS_ROOT="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +ENV_FILE="${ENV_FILE:-${ROOT}/.env}" +PROJECT="${COMPOSE_PROJECT_NAME:-infra}" + +VIDEOS_MOUNT="$(dirname "$VIDEOS_ROOT")" +if ! mountpoint -q "$VIDEOS_MOUNT" 2>/dev/null && [ ! -d "$VIDEOS_ROOT" ]; then + echo "ERRORE: disco video non montato (atteso ${VIDEOS_MOUNT})" + exit 1 +fi + +echo "Destinazione: ${VIDEOS_ROOT}" +df -h "$VIDEOS_ROOT" 2>/dev/null || df -h "$VIDEOS_MOUNT" + +fs_mkdir() { + if mkdir -p "$@" 2>/dev/null; then + return 0 + fi + sudo mkdir -p "$@" +} + +fs_rsync() { + if rsync -aH "$@" 2>/dev/null; then + return 0 + fi + sudo rsync -aH "$@" +} + +fs_mkdir "${VIDEOS_ROOT}/recordings" "${VIDEOS_ROOT}/garage/meta" "${VIDEOS_ROOT}/garage/data" +if [ ! -w "$VIDEOS_ROOT" ]; then + sudo chown -R "$(id -un)":"$(id -gn)" "$VIDEOS_ROOT" +fi + +volume_path() { + local mp + mp="$(docker volume inspect "${PROJECT}_$1" --format '{{ .Mountpoint }}' 2>/dev/null || true)" + [ -n "$mp" ] && [ -d "$mp" ] && echo "$mp" && return 0 + mp="$(sudo docker volume inspect "${PROJECT}_$1" --format '{{ .Mountpoint }}' 2>/dev/null || true)" + [ -n "$mp" ] && echo "$mp" +} + +copy_volume() { + local vol="$1" dest="$2" + local src + src="$(volume_path "$vol")" + if [ -z "$src" ] || [ ! -d "$src" ]; then + echo "Volume ${PROJECT}_${vol} assente, salto copia verso ${dest}" + return 0 + fi + if [ -n "$(sudo ls -A "$dest" 2>/dev/null || true)" ]; then + echo "Già presente in ${dest}, salto copia da ${src}" + return 0 + fi + echo "Copia ${src} → ${dest}..." + fs_rsync "${src}/" "${dest}/" +} + +echo "Arresto servizi che usano recordings/Garage..." +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop mediamtx sidekiq rails garage + +copy_volume recordings "${VIDEOS_ROOT}/recordings" +copy_volume garage_meta "${VIDEOS_ROOT}/garage/meta" +copy_volume garage_data "${VIDEOS_ROOT}/garage/data" + +if ! grep -q '^MATCHLIVETV_VIDEOS_ROOT=' "$ENV_FILE" 2>/dev/null; then + echo "MATCHLIVETV_VIDEOS_ROOT=${VIDEOS_ROOT}" >> "$ENV_FILE" + echo "Aggiunto MATCHLIVETV_VIDEOS_ROOT a ${ENV_FILE}" +fi + +if [ -d "${ROOT}/garage/garage.prod.toml" ]; then + echo "ERRORE: ${ROOT}/garage/garage.prod.toml è una directory (bind mount Docker corrotto)." + echo "Rimuovi la directory e ripristina il file TOML prima di continuare." + exit 1 +fi + +echo "Riavvio stack..." +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --force-recreate mediamtx garage rails sidekiq + +echo "Ridimensiono capacità Garage sul disco video..." +export MATCHLIVETV_VIDEOS_ROOT="$VIDEOS_ROOT" +bash "${ROOT}/scripts/garage_set_capacity.sh" + +echo "Verifica mount:" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T rails \ + bundle exec rails runner \ + 'puts({recordings: `du -sh /recordings 2>/dev/null`.strip, storage: Recordings::Storage.new.backend_name}.inspect)' + +echo "Fatto. Volumi Docker obsoleti (rimuovi dopo verifica):" +for v in recordings garage_data garage_meta; do + docker volume inspect "${PROJECT}_${v}" --format '{{ .Name }} ({{ .Mountpoint }})' 2>/dev/null || true +done +echo " docker volume rm ${PROJECT}_recordings ${PROJECT}_garage_data ${PROJECT}_garage_meta" diff --git a/infra/scripts/setup_garage_production.sh b/infra/scripts/setup_garage_production.sh index 63dad06..92a3417 100755 --- a/infra/scripts/setup_garage_production.sh +++ b/infra/scripts/setup_garage_production.sh @@ -14,7 +14,10 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh" echo "Avvio Garage..." docker compose -f docker-compose.prod.yml --env-file .env up -d garage -avail_gb="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" +videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}" +capacity_df="${videos_root}" +[ -d "$capacity_df" ] || capacity_df="/" +avail_gb="$(df -BG "${capacity_df}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" reserve="${GARAGE_RESERVE_GB:-20}" cap_gb=$((avail_gb - reserve)) [ "$cap_gb" -lt 10 ] && cap_gb=10