Split database config into DATABASE_* components
CI / Build and push docker image (push) Successful in 2m56s

Assemble the Postgres DSN from DATABASE_ENGINE/HOST/PORT/NAME/USER/
PASSWORD/OPTIONS so only the password needs to live in a secret; the
rest can go in a ConfigMap. DATABASE_URL remains a full-DSN override
(used by the sqlite test suite). Credentials are percent-encoded, the
port and options are omitted when empty, and the k8s migrate
initContainer now also reads the config ConfigMap.
This commit is contained in:
2026-09-17 19:46:19 +08:00
parent ab4130a4ca
commit 294a93912d
6 changed files with 181 additions and 15 deletions
+17 -3
View File
@@ -45,6 +45,17 @@ metadata:
data: data:
# In-cluster Redis deployed by this file. # In-cluster Redis deployed by this file.
REDIS_URL: redis://redis.tavolo.svc.cluster.local:6379/0 REDIS_URL: redis://redis.tavolo.svc.cluster.local:6379/0
# Postgres (external, lives in another namespace): everything except the
# password, which is the only entry in the tavolo-secrets Secret.
# Use its Service DNS name, e.g. postgres.<namespace>.svc.cluster.local.
DATABASE_ENGINE: postgres
DATABASE_HOST: REPLACE_ME
DATABASE_PORT: "5432"
DATABASE_NAME: REPLACE_ME
DATABASE_USER: REPLACE_ME
# Extra DSN query parameters appended to the URL (e.g. ssl=require).
# Empty means none.
DATABASE_OPTIONS: ""
# The image bakes STATIC_DIR=/app/web/dist; repeat it here for clarity. # The image bakes STATIC_DIR=/app/web/dist; repeat it here for clarity.
STATIC_DIR: /app/web/dist STATIC_DIR: /app/web/dist
GAME_TTL_SECONDS: "86400" GAME_TTL_SECONDS: "86400"
@@ -72,9 +83,9 @@ metadata:
app.kubernetes.io/part-of: tavolo app.kubernetes.io/part-of: tavolo
type: Opaque type: Opaque
stringData: stringData:
# Postgres lives in another namespace; use its Service DNS name: # The only Postgres secret: the password for DATABASE_USER at
# postgres://USER:PASS@postgres.<namespace>.svc.cluster.local:5432/<db> # DATABASE_HOST (both configured in the tavolo-config ConfigMap).
DATABASE_URL: REPLACE_ME DATABASE_PASSWORD: REPLACE_ME
# Client secret for OIDC_CLIENT_ID at the provider. # Client secret for OIDC_CLIENT_ID at the provider.
OIDC_CLIENT_SECRET: REPLACE_ME OIDC_CLIENT_SECRET: REPLACE_ME
@@ -199,6 +210,9 @@ spec:
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
workingDir: /app workingDir: /app
envFrom: envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
name: tavolo-config
- secretRef: - secretRef:
name: tavolo-secrets name: tavolo-secrets
resources: resources:
+14 -3
View File
@@ -4,7 +4,8 @@ services:
environment: environment:
POSTGRES_DB: tavolo POSTGRES_DB: tavolo
POSTGRES_USER: tavolo POSTGRES_USER: tavolo
POSTGRES_PASSWORD: tavolo # Override via the DATABASE_PASSWORD env var (shell or root .env).
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -71,7 +72,12 @@ services:
working_dir: /app working_dir: /app
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
environment: environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -90,7 +96,12 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# By default the app and browsers reach the mock IdP under the same # By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and # name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port. # OIDC_REDIRECT_URI to use a real provider or a different host port.
+14 -7
View File
@@ -1,10 +1,17 @@
# Postgres # Database (match statistics). The app assembles the DSN from these
POSTGRES_HOST=localhost # parts; DATABASE_PORT may be left unset to use the driver default
POSTGRES_PORT=5432 # (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended
POSTGRES_DB=tavolo # to the URL (e.g. ssl=require); leave empty for none.
POSTGRES_USER=tavolo DATABASE_ENGINE=postgres
POSTGRES_PASSWORD=tavolo DATABASE_HOST=localhost
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo DATABASE_PORT=5432
DATABASE_NAME=tavolo
DATABASE_USER=tavolo
DATABASE_PASSWORD=password
DATABASE_OPTIONS=
# Full-DSN override: when set, the parts above are ignored. Used by the
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any # OIDC (mock-oauth2-server in dev; it does not validate clients, so any
# client id/secret works. For a real IdP like Keycloak, use its values here.) # client id/secret works. For a real IdP like Keycloak, use its values here.)
+8 -1
View File
@@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics | | `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | | `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | | `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | | `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
+43 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from urllib.parse import quote
def _env(name: str, default: Optional[str] = None) -> str: def _env(name: str, default: Optional[str] = None) -> str:
@@ -19,6 +20,34 @@ def _env(name: str, default: Optional[str] = None) -> str:
return value return value
def _database_url_from_parts(engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str) -> str:
"""Assemble a database DSN from individual components.
``user`` and ``password`` are percent-encoded so credentials containing
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
DSN. ``port`` and ``options`` are omitted when empty: a missing port
lets the driver pick its default (5432 for asyncpg). ``options`` is a
raw query string (e.g. ``ssl=require``) appended after a ``?``.
"""
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
database_url: str database_url: str
@@ -54,7 +83,20 @@ class Settings:
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
return Settings( return Settings(
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"), # DATABASE_URL, when set, is used verbatim and the DATABASE_*
# parts below are ignored (sqlite in tests, managed-DB DSNs).
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "tavolo"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
# Unset: the port segment is omitted and the driver default
# (5432 for asyncpg) applies.
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "tavolo"),
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"), oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"), oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
+85
View File
@@ -0,0 +1,85 @@
"""Unit tests for the database DSN assembly in :mod:`tavolo.config`.
``Settings.from_env`` is called directly with a fully replaced
``os.environ`` so no test leaks its ``DATABASE_*`` overrides into the
suite (``tests/__init__.py`` sets ``DATABASE_URL=sqlite://:memory:``
globally for the application tests).
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class DatabaseUrlTests(unittest.TestCase):
def test_defaults_assemble_from_parts(self):
settings = _settings({})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
def test_components_override_defaults(self):
settings = _settings({
"DATABASE_ENGINE": "postgres",
"DATABASE_HOST": "db.internal",
"DATABASE_PORT": "5433",
"DATABASE_NAME": "cards",
"DATABASE_USER": "scopa",
"DATABASE_PASSWORD": "s3cret",
})
self.assertEqual(
settings.database_url,
"postgres://scopa:s3cret@db.internal:5433/cards",
)
def test_options_are_appended_as_query_string(self):
settings = _settings({"DATABASE_OPTIONS": "ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_options_leading_question_mark_is_stripped(self):
settings = _settings({"DATABASE_OPTIONS": "?ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_credentials_are_percent_encoded(self):
settings = _settings({
"DATABASE_USER": "u@x",
"DATABASE_PASSWORD": "p@ss/word:1",
})
self.assertEqual(
settings.database_url,
"postgres://u%40x:p%40ss%2Fword%3A1@localhost/tavolo",
)
def test_database_url_takes_precedence_over_parts(self):
settings = _settings({
"DATABASE_URL": "sqlite://:memory:",
"DATABASE_HOST": "db.internal",
"DATABASE_PASSWORD": "ignored",
})
self.assertEqual(settings.database_url, "sqlite://:memory:")
def test_empty_database_url_falls_back_to_parts(self):
settings = _settings({"DATABASE_URL": ""})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
if __name__ == "__main__":
unittest.main()