Files
tavolo/deploy/k8s/tavolo.yaml
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00

320 lines
9.8 KiB
YAML

# Tavolo — Kubernetes deployment (namespace: tavolo)
#
# Single apply:
# kubectl apply -f deploy/k8s/tavolo.yaml
#
# Replace every value marked REPLACE_ME before applying.
#
# Scope:
# - Postgres and the OIDC provider are NOT part of this file; they are
# deployed in other namespaces and referenced by DNS name.
# - Redis IS included (ephemeral: sessions and live games are disposable,
# mirroring docker-compose's no-volume choice).
#
# Reachability notes:
# - The OIDC issuer URL must resolve from the browser AND from the pods
# (the server discovers the provider lazily on the first login, so
# startup succeeds even when the issuer is unreachable, but logins then
# fail). If the provider is internal-only, you need split DNS or a public
# issuer URL.
# - The Postgres NetworkPolicy (if any) must allow ingress from the
# tavolo namespace.
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
kind: Namespace
metadata:
name: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/part-of: tavolo
---
apiVersion: v1
kind: ConfigMap
metadata:
name: tavolo-config
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
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.<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.
STATIC_DIR: /app/web/dist
GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30"
# CORS (Starlette-compatible middleware). Disabled unless
# CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
# served from the same origin. See server/.env.example for details.
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
# CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600"
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
# process + tavolo metrics and logs via OTLP/HTTP, disabled unless
# OTEL_ENABLED is set.
# OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
# OIDC (the provider lives in another namespace). Each provider is an
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
# to tavolo-secrets as well.
OIDC_0_PROVIDER: default
OIDC_0_CLIENT_ID: tavolo
OIDC_0_POST_LOGIN_REDIRECT: /
OIDC_0_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_0_ISSUER: REPLACE_ME
# Public callback URL of this deployment, e.g.
# http://<host>:<port>/auth/default/callback (must be allowed at the
# provider). When unset it defaults to
# http://localhost:8080/auth/<provider>/callback.
OIDC_0_REDIRECT_URI: REPLACE_ME
---
apiVersion: v1
kind: Secret
metadata:
name: tavolo-secrets
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
type: Opaque
stringData:
# 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_0_CLIENT_ID at the provider.
OIDC_0_CLIENT_SECRET: REPLACE_ME
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
# case after a restart users log in again and games in progress expire.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
# Redis is a singleton here; avoid a window with two masters on rollout.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # redis user in the official image
fsGroup: 999
containers:
- name: redis
image: redis:8-alpine
# Snapshots would only live on an emptyDir anyway: skip them.
args: ["--save", "", "--appendonly", "no"]
ports:
- name: redis
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 5
livenessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
---
# The app (backend + compiled SPA + WebSocket endpoint, one image).
# The "migrate" initContainer runs `tavolo migrate` before the app starts,
# so this single file can be applied safely in any order. Migrations are
# idempotent, but if you ever scale above 1 replica, move this to a Job to
# avoid concurrent upgrades.
apiVersion: apps/v1
kind: Deployment
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
# Let WebSocket connections drain on rollout.
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
initContainers:
- name: migrate
image: gitea.woggioni.net/woggioni/tavolo:latest
# sqlx migrations are embedded in the binary.
command: ["tavolo", "migrate"]
envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
containers:
- name: tavolo
# Pin a release/* tag for production instead of :latest.
image: gitea.woggioni.net/woggioni/tavolo:latest
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
# No session affinity needed: sessions and live games live in Redis.
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
ports:
- name: http
port: 80
targetPort: http