diff --git a/deploy/k8s/tavolo.yaml b/deploy/k8s/tavolo.yaml index a580c4d..5c67fca 100644 --- a/deploy/k8s/tavolo.yaml +++ b/deploy/k8s/tavolo.yaml @@ -45,6 +45,17 @@ metadata: data: # In-cluster Redis deployed by this file. 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..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. STATIC_DIR: /app/web/dist GAME_TTL_SECONDS: "86400" @@ -72,9 +83,9 @@ metadata: app.kubernetes.io/part-of: tavolo type: Opaque stringData: - # Postgres lives in another namespace; use its Service DNS name: - # postgres://USER:PASS@postgres..svc.cluster.local:5432/ - DATABASE_URL: REPLACE_ME + # The only Postgres secret: the password for DATABASE_USER at + # DATABASE_HOST (both configured in the tavolo-config ConfigMap). + DATABASE_PASSWORD: REPLACE_ME # Client secret for OIDC_CLIENT_ID at the provider. OIDC_CLIENT_SECRET: REPLACE_ME @@ -199,6 +210,9 @@ spec: command: ["aerich", "upgrade"] workingDir: /app envFrom: + # Migrations need the non-secret DATABASE_* parts too. + - configMapRef: + name: tavolo-config - secretRef: name: tavolo-secrets resources: diff --git a/docker-compose.yml b/docker-compose.yml index 4f3aaf5..129f12e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,8 @@ services: environment: POSTGRES_DB: tavolo POSTGRES_USER: tavolo - POSTGRES_PASSWORD: tavolo + # Override via the DATABASE_PASSWORD env var (shell or root .env). + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password} ports: - "5432:5432" volumes: @@ -71,7 +72,12 @@ services: working_dir: /app command: ["aerich", "upgrade"] 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: postgres: condition: service_healthy @@ -90,7 +96,12 @@ services: redis: condition: service_healthy 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 # name (see README /etc/hosts note); override OIDC_ISSUER and # OIDC_REDIRECT_URI to use a real provider or a different host port. diff --git a/server/.env.example b/server/.env.example index b45ce23..00ce720 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,10 +1,17 @@ -# Postgres -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=tavolo -POSTGRES_USER=tavolo -POSTGRES_PASSWORD=tavolo -DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo +# Database (match statistics). The app assembles the DSN from these +# parts; DATABASE_PORT may be left unset to use the driver default +# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended +# to the URL (e.g. ssl=require); leave empty for none. +DATABASE_ENGINE=postgres +DATABASE_HOST=localhost +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 # client id/secret works. For a real IdP like Keycloak, use its values here.) diff --git a/server/README.md b/server/README.md index 2bcae4a..3664ead 100644 --- a/server/README.md +++ b/server/README.md @@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`): | 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 | | `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | | `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | diff --git a/server/src/tavolo/config.py b/server/src/tavolo/config.py index ff111e1..4b48d61 100644 --- a/server/src/tavolo/config.py +++ b/server/src/tavolo/config.py @@ -8,6 +8,7 @@ from __future__ import annotations import os from dataclasses import dataclass from typing import Optional +from urllib.parse import quote def _env(name: str, default: Optional[str] = None) -> str: @@ -19,6 +20,34 @@ def _env(name: str, default: Optional[str] = None) -> str: 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) class Settings: database_url: str @@ -54,7 +83,20 @@ class Settings: @staticmethod def from_env() -> "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_client_id=_env("OIDC_CLIENT_ID", "tavolo"), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..f6947e0 --- /dev/null +++ b/server/tests/test_config.py @@ -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()