From 10ed6f6c2fe240278c27b1147c2a2910534b5d56 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Fri, 24 Apr 2026 13:09:46 +0200 Subject: [PATCH] feat: implementazione V1 - app.py, Dockerfile, docker-compose, .env.example, requirements --- .env.example | 17 ++++ Dockerfile | 10 +++ README.md | 26 ++++--- app.py | 189 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 7 ++ progress.md | 28 +++---- requirements.txt | 2 + 7 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac95c83 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Credenziali database Supabase +# Ricavabili da: Supabase Dashboard > Project Settings > Database +SUPABASE_DB_HOST=db..supabase.co +SUPABASE_DB_PORT=5432 +SUPABASE_DB_NAME=postgres +SUPABASE_DB_USER=postgres +SUPABASE_DB_PASSWORD= + +# Intervallo tra un keep-alive e il successivo (in minuti) +# Default 4320 = 72 ore (3 volte a settimana) +PING_INTERVAL_MINUTES=4320 + +# Query di keep-alive (non modificare se non necessario) +PING_QUERY=SELECT 1; + +# Timezone del container (per i log) +TZ=Europe/Rome diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbedc2b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +CMD ["python", "-u", "app.py"] diff --git a/README.md b/README.md index 9b196e2..5e68af9 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,12 @@ E' consigliato mantenere nel repository anche un file `.env.example` con le sole ### Esempio di `.env.example` ```env -SUPABASE_DB_HOST= +SUPABASE_DB_HOST=db..supabase.co SUPABASE_DB_PORT=5432 -SUPABASE_DB_NAME= -SUPABASE_DB_USER= +SUPABASE_DB_NAME=postgres +SUPABASE_DB_USER=postgres SUPABASE_DB_PASSWORD= -PING_INTERVAL_MINUTES=30 +PING_INTERVAL_MINUTES=4320 PING_QUERY=SELECT 1; TZ=Europe/Rome ``` @@ -49,7 +49,7 @@ TZ=Europe/Rome - `SUPABASE_DB_NAME`: nome del database. - `SUPABASE_DB_USER`: utente di connessione. - `SUPABASE_DB_PASSWORD`: password di connessione. -- `PING_INTERVAL_MINUTES`: intervallo tra un keep-alive e il successivo. +- `PING_INTERVAL_MINUTES`: intervallo tra un keep-alive e il successivo. Default `4320` (72 ore, circa 3 volte a settimana). - `PING_QUERY`: query leggera da eseguire per generare attivita'. - `TZ`: timezone del container per logging e scheduling coerenti. @@ -105,16 +105,20 @@ Stop: docker compose down ``` -## Struttura minima attesa del progetto +## Struttura del progetto ```text . -|-- .env -|-- .env.example +|-- .env # credenziali locali (non versionato) +|-- .env.example # modello variabili senza segreti +|-- .gitignore +|-- app.py # entrypoint del servizio +|-- docker-compose.yml |-- Dockerfile -|-- requirements.txt -|-- app.py -`-- README.md +|-- prd.md # product requirements document +|-- progress.md # piano e stato di sviluppo +|-- README.md +`-- requirements.txt ``` ## Note operative diff --git a/app.py b/app.py new file mode 100644 index 0000000..d2d436e --- /dev/null +++ b/app.py @@ -0,0 +1,189 @@ +""" +supabase-pinger +--------------- +Servizio long-running che esegue periodicamente una query di keep-alive +verso un database Supabase/PostgreSQL per evitarne la sospensione automatica +nel free tier. + +Configurazione: file .env (vedi .env.example) +""" + +import logging +import os +import signal +import sys +import time + +import psycopg2 +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stdout, +) +log = logging.getLogger("supabase-pinger") + +# --------------------------------------------------------------------------- +# Shutdown ordinato +# --------------------------------------------------------------------------- + +_shutdown = False + + +def _handle_signal(signum, _frame): + global _shutdown + log.info("Segnale di arresto ricevuto (%s). Chiusura in corso...", signum) + _shutdown = True + + +signal.signal(signal.SIGTERM, _handle_signal) +signal.signal(signal.SIGINT, _handle_signal) + +# --------------------------------------------------------------------------- +# Configurazione +# --------------------------------------------------------------------------- + +_REQUIRED_VARS = [ + "SUPABASE_DB_HOST", + "SUPABASE_DB_PORT", + "SUPABASE_DB_NAME", + "SUPABASE_DB_USER", + "SUPABASE_DB_PASSWORD", +] + + +def load_config() -> dict: + """Carica e valida la configurazione dal file .env o dall'ambiente.""" + load_dotenv() + + missing = [v for v in _REQUIRED_VARS if not os.environ.get(v)] + if missing: + log.error( + "Variabili di ambiente obbligatorie mancanti: %s", ", ".join(missing) + ) + sys.exit(1) + + try: + port = int(os.environ["SUPABASE_DB_PORT"]) + except ValueError: + log.error("SUPABASE_DB_PORT deve essere un numero intero.") + sys.exit(1) + + try: + interval = int(os.environ.get("PING_INTERVAL_MINUTES", "4320")) + if interval <= 0: + raise ValueError + except ValueError: + log.error("PING_INTERVAL_MINUTES deve essere un numero intero positivo.") + sys.exit(1) + + return { + "host": os.environ["SUPABASE_DB_HOST"], + "port": port, + "dbname": os.environ["SUPABASE_DB_NAME"], + "user": os.environ["SUPABASE_DB_USER"], + "password": os.environ["SUPABASE_DB_PASSWORD"], + "query": os.environ.get("PING_QUERY", "SELECT 1;"), + "interval_minutes": interval, + } + +# --------------------------------------------------------------------------- +# Keep-alive +# --------------------------------------------------------------------------- + + +def ping(cfg: dict) -> bool: + """ + Apre una connessione, esegue la query di keep-alive e chiude tutto. + Restituisce True in caso di successo, False in caso di errore. + Non espone mai credenziali nei log. + """ + conn = None + try: + log.info( + "Connessione a %s:%d/%s...", + cfg["host"], + cfg["port"], + cfg["dbname"], + ) + conn = psycopg2.connect( + host=cfg["host"], + port=cfg["port"], + dbname=cfg["dbname"], + user=cfg["user"], + password=cfg["password"], + connect_timeout=10, + ) + with conn.cursor() as cur: + log.info("Esecuzione query di keep-alive: %s", cfg["query"]) + cur.execute(cfg["query"]) + log.info("Keep-alive eseguito con successo.") + return True + except psycopg2.OperationalError as exc: + log.error("Errore di connessione al database: %s", exc) + return False + except psycopg2.Error as exc: + log.error("Errore durante l'esecuzione della query: %s", exc) + return False + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + +# --------------------------------------------------------------------------- +# Loop principale +# --------------------------------------------------------------------------- + + +def run(cfg: dict) -> None: + """Loop principale del servizio.""" + interval_seconds = cfg["interval_minutes"] * 60 + + log.info("=" * 60) + log.info("supabase-pinger avviato") + log.info("Host : %s:%d/%s", cfg["host"], cfg["port"], cfg["dbname"]) + log.info("Intervallo : %d minuti", cfg["interval_minutes"]) + log.info("Query : %s", cfg["query"]) + log.info("=" * 60) + + while not _shutdown: + success = ping(cfg) + + if not success: + log.warning( + "Keep-alive fallito. Nuovo tentativo al prossimo ciclo." + ) + + if _shutdown: + break + + next_run = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(time.time() + interval_seconds), + ) + log.info("Prossima esecuzione: %s. In attesa...", next_run) + + # Sleep a blocchi per rispondere rapidamente a SIGTERM/SIGINT + elapsed = 0 + while elapsed < interval_seconds and not _shutdown: + time.sleep(min(10, interval_seconds - elapsed)) + elapsed += 10 + + log.info("supabase-pinger arrestato.") + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + config = load_config() + run(config) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01433a9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + supabase-pinger: + build: . + container_name: supabase-pinger + env_file: + - .env + restart: unless-stopped diff --git a/progress.md b/progress.md index ab8d582..fdaa273 100644 --- a/progress.md +++ b/progress.md @@ -6,10 +6,12 @@ Data riferimento: 2026-04-24 Il progetto ha oggi: -- un README iniziale con obiettivo e modalita' d'uso target; -- un PRD esteso con requisiti funzionali e non funzionali; -- note di connessione tecniche da trattare con cautela; -- nessuna implementazione applicativa ancora presente. +- README, PRD e progress.md committati su remote; +- `.gitignore` con esclusione di `.env` e `connessione.md`; +- `app.py` — entrypoint Python con config loader, database client, loop resiliente e shutdown ordinato; +- `requirements.txt` con dipendenze minime (`psycopg2-binary`, `python-dotenv`); +- `.env.example` completo; +- `Dockerfile` e `docker-compose.yml`. ## Obiettivo di Sviluppo @@ -52,7 +54,7 @@ Verifica: Stato: -- da fare +- **completata** — `connessione.md` non tracciato, `.env` escluso da `.gitignore`, nessuna credenziale in chiaro nel repository. ### Fase 1. Bootstrap del progetto Python @@ -81,7 +83,7 @@ Verifica: Stato: -- da fare +- **completata** — `app.py` con logging strutturato, `requirements.txt` con `psycopg2-binary` e `python-dotenv`, sintassi verificata. ### Fase 2. Configurazione e validazione environment @@ -110,7 +112,7 @@ Verifica: Stato: -- da fare +- **completata** — `load_config()` in `app.py` valida tutte le variabili obbligatorie con messaggi di errore espliciti e uscita controllata; `.env.example` coerente con PRD e README. ### Fase 3. Connessione a Supabase/PostgreSQL @@ -139,7 +141,7 @@ Verifica: Stato: -- da fare +- **completata** — funzione `ping()` in `app.py` con connect_timeout, gestione `OperationalError` e `Error`, chiusura connessione in `finally`, nessun segreto nei log. ### Fase 4. Loop di keep-alive e resilienza runtime @@ -168,7 +170,7 @@ Verifica: Stato: -- da fare +- **completata** — `run()` in `app.py` con loop `while not _shutdown`, sleep a blocchi da 10s per risposta rapida a SIGTERM/SIGINT, handler segnali registrati, log di next-run ad ogni ciclo. ### Fase 5. Containerizzazione Docker @@ -197,7 +199,7 @@ Verifica: Stato: -- da fare +- **completata** — `Dockerfile` con python:3.12-slim, flag `-u` per output non bufferizzato, `docker-compose.yml` con `restart: unless-stopped`. ### Fase 6. Documentazione operativa e rifinitura V1 @@ -226,7 +228,7 @@ Verifica: Stato: -- da fare +- in corso ## Backlog Post-V1 @@ -261,6 +263,6 @@ La V1 e' completata quando: ## Prossima Attivita' Operativa -Task raccomandato immediato: +Fasi 0-5 completate. In corso: Fase 6 — allineamento README con struttura reale e intervallo di ping definitivo (`4320 min = 72 ore`). -- eseguire la Fase 0 e poi creare lo scheletro minimo della V1 con `app.py`, `requirements.txt`, `.env.example` e `Dockerfile`. +Prossima verifica operativa: `docker build` + avvio container con `.env` reale per confermare funzionamento end-to-end. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32f676f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +psycopg2-binary==2.9.9 +python-dotenv==1.0.1