Author SHA1 Message Date
woggioni 7165429dd5 Add preliminary support for multiple card games
A game-type registry (server/src/tavolo/games.py) is now the single
source of truth for the games the platform can host; only scopone
scientifico is registered so far. GET /api/game-types exposes it for the
lobby's new game dropdown, and POST /api/games accepts a validated
game_type (default scopone_scientifico) which is carried on the live
GameState and onto each finished Match row (new indexed column,
migration 1_20260916235833_update), so statistics can be scoped per
game: /api/me/matches and /api/leaderboard take an optional game_type
filter and every serialized match includes its game_type.

Game states serialized before this change still load with the default
game type.
2026-09-17 00:25:58 +00:00
woggioni ff0d77fac6 Serve static assets with Granian and add YAML-configurable logging
Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css
under /static (the release build uses --public-url /static/) and the card
images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/
DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves
the SPA shell (index.html) at / and for client-side routes.

Every module logs through getLogger(__name__): lifecycle and business events
at INFO, per-move and store detail at DEBUG. The built-in default writes
DEBUG to the console; LOGGING_CONFIG points at a YAML file in the
logging.config.dictConfig schema to take over the configuration. PyYAML
becomes a direct dependency.
2026-09-17 00:25:58 +00:00
woggioni b9964f6005 Add Kubernetes manifest for the tavolo namespace
Single-file deployment under deploy/k8s/tavolo.yaml:

- Namespace, ConfigMap and Secret (placeholders) for the app
- Ephemeral Redis Deployment + Service (sessions and live games are
  disposable, mirroring the docker-compose no-volume choice)
- App Deployment with an aerich-migrate initContainer so the schema is
  upgraded before rollout, /api/health probes and a hardened pod
  security context
- ClusterIP Service; Postgres and the OIDC provider stay in their own
  namespaces and are referenced by DNS
2026-09-17 00:25:58 +00:00
6 changed files with 15 additions and 181 deletions
+3 -17
View File
@@ -45,17 +45,6 @@ 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"
@@ -83,9 +72,9 @@ metadata:
app.kubernetes.io/part-of: tavolo app.kubernetes.io/part-of: tavolo
type: Opaque type: Opaque
stringData: stringData:
# The only Postgres secret: the password for DATABASE_USER at # Postgres lives in another namespace; use its Service DNS name:
# DATABASE_HOST (both configured in the tavolo-config ConfigMap). # postgres://USER:PASS@postgres.<namespace>.svc.cluster.local:5432/<db>
DATABASE_PASSWORD: REPLACE_ME DATABASE_URL: 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
@@ -210,9 +199,6 @@ 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:
+3 -14
View File
@@ -4,8 +4,7 @@ services:
environment: environment:
POSTGRES_DB: tavolo POSTGRES_DB: tavolo
POSTGRES_USER: tavolo POSTGRES_USER: tavolo
# Override via the DATABASE_PASSWORD env var (shell or root .env). POSTGRES_PASSWORD: tavolo
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -72,12 +71,7 @@ services:
working_dir: /app working_dir: /app
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
environment: environment:
DATABASE_ENGINE: postgres DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
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
@@ -96,12 +90,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_ENGINE: postgres DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
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.
+7 -14
View File
@@ -1,17 +1,10 @@
# Database (match statistics). The app assembles the DSN from these # Postgres
# parts; DATABASE_PORT may be left unset to use the driver default POSTGRES_HOST=localhost
# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended POSTGRES_PORT=5432
# to the URL (e.g. ssl=require); leave empty for none. POSTGRES_DB=tavolo
DATABASE_ENGINE=postgres POSTGRES_USER=tavolo
DATABASE_HOST=localhost POSTGRES_PASSWORD=tavolo
DATABASE_PORT=5432 DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
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.)
+1 -8
View File
@@ -44,14 +44,7 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver | | `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
| `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 |
+1 -43
View File
@@ -8,7 +8,6 @@ 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:
@@ -20,34 +19,6 @@ 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
@@ -83,20 +54,7 @@ class Settings:
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
return Settings( return Settings(
# DATABASE_URL, when set, is used verbatim and the DATABASE_* database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
# 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
@@ -1,85 +0,0 @@
"""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()