feat: implementazione V1 - app.py, Dockerfile, docker-compose, .env.example, requirements
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# Credenziali database Supabase
|
||||||
|
# Ricavabili da: Supabase Dashboard > Project Settings > Database
|
||||||
|
SUPABASE_DB_HOST=db.<project-ref>.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
|
||||||
+10
@@ -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"]
|
||||||
@@ -32,12 +32,12 @@ E' consigliato mantenere nel repository anche un file `.env.example` con le sole
|
|||||||
### Esempio di `.env.example`
|
### Esempio di `.env.example`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SUPABASE_DB_HOST=
|
SUPABASE_DB_HOST=db.<project-ref>.supabase.co
|
||||||
SUPABASE_DB_PORT=5432
|
SUPABASE_DB_PORT=5432
|
||||||
SUPABASE_DB_NAME=
|
SUPABASE_DB_NAME=postgres
|
||||||
SUPABASE_DB_USER=
|
SUPABASE_DB_USER=postgres
|
||||||
SUPABASE_DB_PASSWORD=
|
SUPABASE_DB_PASSWORD=
|
||||||
PING_INTERVAL_MINUTES=30
|
PING_INTERVAL_MINUTES=4320
|
||||||
PING_QUERY=SELECT 1;
|
PING_QUERY=SELECT 1;
|
||||||
TZ=Europe/Rome
|
TZ=Europe/Rome
|
||||||
```
|
```
|
||||||
@@ -49,7 +49,7 @@ TZ=Europe/Rome
|
|||||||
- `SUPABASE_DB_NAME`: nome del database.
|
- `SUPABASE_DB_NAME`: nome del database.
|
||||||
- `SUPABASE_DB_USER`: utente di connessione.
|
- `SUPABASE_DB_USER`: utente di connessione.
|
||||||
- `SUPABASE_DB_PASSWORD`: password 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'.
|
- `PING_QUERY`: query leggera da eseguire per generare attivita'.
|
||||||
- `TZ`: timezone del container per logging e scheduling coerenti.
|
- `TZ`: timezone del container per logging e scheduling coerenti.
|
||||||
|
|
||||||
@@ -105,16 +105,20 @@ Stop:
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Struttura minima attesa del progetto
|
## Struttura del progetto
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|-- .env
|
|-- .env # credenziali locali (non versionato)
|
||||||
|-- .env.example
|
|-- .env.example # modello variabili senza segreti
|
||||||
|
|-- .gitignore
|
||||||
|
|-- app.py # entrypoint del servizio
|
||||||
|
|-- docker-compose.yml
|
||||||
|-- Dockerfile
|
|-- Dockerfile
|
||||||
|-- requirements.txt
|
|-- prd.md # product requirements document
|
||||||
|-- app.py
|
|-- progress.md # piano e stato di sviluppo
|
||||||
`-- README.md
|
|-- README.md
|
||||||
|
`-- requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Note operative
|
## Note operative
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
supabase-pinger:
|
||||||
|
build: .
|
||||||
|
container_name: supabase-pinger
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
+15
-13
@@ -6,10 +6,12 @@ Data riferimento: 2026-04-24
|
|||||||
|
|
||||||
Il progetto ha oggi:
|
Il progetto ha oggi:
|
||||||
|
|
||||||
- un README iniziale con obiettivo e modalita' d'uso target;
|
- README, PRD e progress.md committati su remote;
|
||||||
- un PRD esteso con requisiti funzionali e non funzionali;
|
- `.gitignore` con esclusione di `.env` e `connessione.md`;
|
||||||
- note di connessione tecniche da trattare con cautela;
|
- `app.py` — entrypoint Python con config loader, database client, loop resiliente e shutdown ordinato;
|
||||||
- nessuna implementazione applicativa ancora presente.
|
- `requirements.txt` con dipendenze minime (`psycopg2-binary`, `python-dotenv`);
|
||||||
|
- `.env.example` completo;
|
||||||
|
- `Dockerfile` e `docker-compose.yml`.
|
||||||
|
|
||||||
## Obiettivo di Sviluppo
|
## Obiettivo di Sviluppo
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
Stato:
|
||||||
|
|
||||||
- da fare
|
- **completata** — `connessione.md` non tracciato, `.env` escluso da `.gitignore`, nessuna credenziale in chiaro nel repository.
|
||||||
|
|
||||||
### Fase 1. Bootstrap del progetto Python
|
### Fase 1. Bootstrap del progetto Python
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
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
|
### Fase 2. Configurazione e validazione environment
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
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
|
### Fase 3. Connessione a Supabase/PostgreSQL
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
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
|
### Fase 4. Loop di keep-alive e resilienza runtime
|
||||||
|
|
||||||
@@ -168,7 +170,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
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
|
### Fase 5. Containerizzazione Docker
|
||||||
|
|
||||||
@@ -197,7 +199,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
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
|
### Fase 6. Documentazione operativa e rifinitura V1
|
||||||
|
|
||||||
@@ -226,7 +228,7 @@ Verifica:
|
|||||||
|
|
||||||
Stato:
|
Stato:
|
||||||
|
|
||||||
- da fare
|
- in corso
|
||||||
|
|
||||||
## Backlog Post-V1
|
## Backlog Post-V1
|
||||||
|
|
||||||
@@ -261,6 +263,6 @@ La V1 e' completata quando:
|
|||||||
|
|
||||||
## Prossima Attivita' Operativa
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
psycopg2-binary==2.9.9
|
||||||
|
python-dotenv==1.0.1
|
||||||
Reference in New Issue
Block a user