Split database config into DATABASE_* components
CI / Build and push docker image (push) Successful in 2m56s
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:
+17
-3
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user