Files
tavolo/deploy/k8s/tavolo.yaml
T
woggioni a89bd06010
CI / Build and push docker image (push) Successful in 2m27s
Support multiple OIDC providers
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 20:38:51 +08:00

319 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
# (kaya does discovery 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 (kaya-cors' CorsMixin). 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 (kaya-otel + contrib instrumentations): traces
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
# disabled unless OTEL_ENABLED is set. The image bundles the otel extra.
# 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 `aerich upgrade` 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
# aerich reads [tool.aerich] from pyproject.toml in /app.
command: ["aerich", "upgrade"]
workingDir: /app
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