Harden database startup for Portainer

This commit is contained in:
masterdev
2026-06-16 22:34:07 +01:00
parent 038570646c
commit 6bee04a8e1
5 changed files with 51 additions and 4 deletions

View File

@@ -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 `mqtt/mosquitto.conf`. This avoids file-versus-directory mount errors in
Portainer Git stacks. 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 ### 4. Verify
Open: Open:

View File

@@ -1,5 +1,6 @@
from functools import lru_cache from functools import lru_cache
from sqlalchemy.engine import URL
from pydantic_settings import BaseSettings, SettingsConfigDict 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") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Plast Track MVP" 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_host: str = "mqtt"
mqtt_port: int = 1883 mqtt_port: int = 1883
mqtt_topic: str = "plasttrack/machines/+/events" mqtt_topic: str = "plasttrack/machines/+/events"
mqtt_client_id: str = "plast-track-backend" mqtt_client_id: str = "plast-track-backend"
frontend_origin: str = "http://localhost:5173" frontend_origin: str = "http://localhost:5173"
downtime_check_interval_sec: int = 5 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 @lru_cache

View File

@@ -11,7 +11,7 @@ class Base(DeclarativeBase):
settings = get_settings() 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) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)

View File

@@ -6,7 +6,8 @@ from datetime import date, datetime, timezone
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware 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 sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
@@ -112,8 +113,23 @@ async def downtime_monitor() -> None:
await asyncio.sleep(settings.downtime_check_interval_sec) 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await wait_for_database()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as session: with SessionLocal() as session:
seed_defaults(session) seed_defaults(session)

View File

@@ -30,7 +30,11 @@ services:
build: build:
context: ./backend context: ./backend
environment: 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_HOST: mqtt
MQTT_PORT: 1883 MQTT_PORT: 1883
MQTT_TOPIC: plasttrack/machines/+/events MQTT_TOPIC: plasttrack/machines/+/events