From 6bee04a8e171afe2577ac3a21221a6cfd78a5812 Mon Sep 17 00:00:00 2001 From: masterdev Date: Tue, 16 Jun 2026 22:34:07 +0100 Subject: [PATCH] Harden database startup for Portainer --- README.md | 6 ++++++ backend/app/config.py | 23 ++++++++++++++++++++++- backend/app/database.py | 2 +- backend/app/main.py | 18 +++++++++++++++++- docker-compose.yml | 6 +++++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4de2758..0b2df5a 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ not need to bind-mount individual files like `database/init.sql` or `mqtt/mosquitto.conf`. This avoids file-versus-directory mount errors in Portainer Git stacks. +The backend receives PostgreSQL connection settings as separate variables +(`POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`) and +builds the SQLAlchemy URL internally. This avoids broken database URLs when the +password contains special characters. The backend also retries the database +connection during startup to tolerate Portainer service/DNS startup delays. + ### 4. Verify Open: diff --git a/backend/app/config.py b/backend/app/config.py index 0960f88..a09bdcf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,6 @@ from functools import lru_cache +from sqlalchemy.engine import URL from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,13 +8,33 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") app_name: str = "Plast Track MVP" - database_url: str = "postgresql+psycopg://plasttrack:plasttrack@database:5432/plasttrack" + database_url: str | None = None + postgres_host: str = "database" + postgres_port: int = 5432 + postgres_db: str = "plasttrack" + postgres_user: str = "plasttrack" + postgres_password: str = "plasttrack" mqtt_host: str = "mqtt" mqtt_port: int = 1883 mqtt_topic: str = "plasttrack/machines/+/events" mqtt_client_id: str = "plast-track-backend" frontend_origin: str = "http://localhost:5173" downtime_check_interval_sec: int = 5 + database_startup_retries: int = 30 + database_startup_retry_delay_sec: float = 2.0 + + @property + def sqlalchemy_database_url(self) -> str: + if self.database_url: + return self.database_url + return URL.create( + "postgresql+psycopg", + username=self.postgres_user, + password=self.postgres_password, + host=self.postgres_host, + port=self.postgres_port, + database=self.postgres_db, + ).render_as_string(hide_password=False) @lru_cache diff --git a/backend/app/database.py b/backend/app/database.py index aaca5b4..028175e 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -11,7 +11,7 @@ class Base(DeclarativeBase): settings = get_settings() -engine = create_engine(settings.database_url, pool_pre_ping=True) +engine = create_engine(settings.sqlalchemy_database_url, pool_pre_ping=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) diff --git a/backend/app/main.py b/backend/app/main.py index 1a013a5..2410703 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,8 @@ from datetime import date, datetime, timezone from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import func, select +from sqlalchemy import func, select, text +from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from .config import get_settings @@ -112,8 +113,23 @@ async def downtime_monitor() -> None: await asyncio.sleep(settings.downtime_check_interval_sec) +async def wait_for_database() -> None: + last_error: OperationalError | None = None + for _ in range(settings.database_startup_retries): + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return + except OperationalError as error: + last_error = error + await asyncio.sleep(settings.database_startup_retry_delay_sec) + if last_error is not None: + raise last_error + + @asynccontextmanager async def lifespan(app: FastAPI): + await wait_for_database() Base.metadata.create_all(bind=engine) with SessionLocal() as session: seed_defaults(session) diff --git a/docker-compose.yml b/docker-compose.yml index c07ee4c..e2f901f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,11 @@ services: build: context: ./backend environment: - DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-plasttrack}:${POSTGRES_PASSWORD:-plasttrack}@database:5432/${POSTGRES_DB:-plasttrack} + POSTGRES_HOST: database + POSTGRES_PORT: 5432 + POSTGRES_DB: ${POSTGRES_DB:-plasttrack} + POSTGRES_USER: ${POSTGRES_USER:-plasttrack} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plasttrack} MQTT_HOST: mqtt MQTT_PORT: 1883 MQTT_TOPIC: plasttrack/machines/+/events