Files
tavolo/server/crates/tavolo-app/src/config.rs
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

309 lines
11 KiB
Rust

//! Environment-driven configuration for the tavolo application.
//!
//! Mirrors `tavolo.config` from the Python implementation: read environment
//! variables into a plain struct, with the same defaults and validation.
use std::collections::BTreeSet;
use regex::Regex;
use tavolo_platform::oidc::OidcProviderConfig;
const OIDC_VARIABLES: [&str; 7] = [
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
];
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn env_or(name: &str, default: &str) -> String {
env(name).unwrap_or_else(|| default.to_string())
}
fn env_list(name: &str) -> Option<Vec<String>> {
let value = std::env::var(name).ok()?;
if value.trim().is_empty() {
return None;
}
Some(
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect(),
)
}
fn env_bool(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(value) if !value.is_empty() => {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
_ => default,
}
}
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
.iter()
.copied()
.filter(|name| env(name).is_some())
.collect();
if !legacy.is_empty() {
let renamed: Vec<String> = legacy
.iter()
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
.collect();
return Err(format!(
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
renamed.join(", ")
));
}
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
let mut indices: BTreeSet<usize> = BTreeSet::new();
for (name, _) in std::env::vars() {
let Some(captures) = pattern.captures(&name) else {
continue;
};
let variable = captures.get(2).expect("group 2").as_str();
if !OIDC_VARIABLES.contains(&variable) {
return Err(format!(
"unknown OIDC environment variable {name:?}; expected one of {}",
OIDC_VARIABLES
.iter()
.map(|variable| format!("OIDC_<n>_{variable}"))
.collect::<Vec<_>>()
.join(", ")
));
}
indices.insert(
captures
.get(1)
.expect("group 1")
.as_str()
.parse()
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
);
}
if indices.is_empty() {
return Ok(vec![OidcProviderConfig {
provider: "default".into(),
issuer: DEFAULT_OIDC_ISSUER.into(),
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}]);
}
let max = *indices.iter().next_back().expect("non-empty");
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
if !missing.is_empty() {
return Err(format!(
"OIDC provider indices must be contiguous from 0; missing {}",
missing
.iter()
.map(|index| format!("OIDC_{index}_*"))
.collect::<Vec<_>>()
.join(", ")
));
}
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for index in indices {
let prefix = format!("OIDC_{index}_");
let required = |variable: &str| -> Result<String, String> {
env(&format!("{prefix}{variable}"))
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
};
let provider = required("PROVIDER")?;
if !provider_name.is_match(&provider) {
return Err(format!(
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
));
}
if !seen.insert(provider.clone()) {
return Err(format!("duplicate OIDC provider name {provider:?}"));
}
providers.push(OidcProviderConfig {
issuer: required("ISSUER")?,
client_id: required("CLIENT_ID")?,
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
redirect_uri: env_or(
&format!("{prefix}REDIRECT_URI"),
&format!("http://localhost:8080/auth/{provider}/callback"),
),
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
provider,
});
}
Ok(providers)
}
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
/// which leaves the RFC 3986 unreserved characters alone.
fn quote(value: &str) -> String {
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'_')
.remove(b'-')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
}
fn database_url_from_parts(
engine: &str,
user: &str,
password: Option<&str>,
host: &str,
port: &str,
name: &str,
options: &str,
) -> String {
let mut netloc = quote(user);
if let Some(password) = password {
if !password.is_empty() {
netloc.push(':');
netloc.push_str(&quote(password));
}
}
netloc.push('@');
netloc.push_str(host);
if !port.is_empty() {
netloc.push(':');
netloc.push_str(port);
}
let mut url = format!("{engine}://{netloc}/{name}");
let options = options.trim_start_matches('?');
if !options.is_empty() {
url.push('?');
url.push_str(options);
}
url
}
#[derive(Debug, Clone)]
pub struct Settings {
pub database_url: String,
pub oidc_providers: Vec<OidcProviderConfig>,
pub app_host: String,
pub app_port: u16,
pub redis_url: Option<String>,
pub game_ttl_seconds: i64,
pub static_dir: String,
pub hand_ack_timeout_seconds: i64,
pub turn_timeout_seconds: i64,
pub deadline_heartbeat_ms: u64,
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
pub logging_config: Option<String>,
pub cors_allow_origins: Option<Vec<String>>,
pub cors_allow_origin_regex: Option<String>,
pub cors_allow_methods: Option<Vec<String>>,
pub cors_allow_headers: Option<Vec<String>>,
pub cors_allow_credentials: bool,
pub cors_expose_headers: Option<Vec<String>>,
pub cors_max_age: i64,
pub otel_enabled: bool,
pub otel_service_name: String,
pub otel_exporter_endpoint: Option<String>,
pub otel_exporter_headers: Option<Vec<String>>,
pub otel_excluded_paths: Vec<String>,
pub otel_metric_export_interval_millis: u64,
pub otel_metrics_poll_interval_seconds: u64,
}
impl Settings {
pub fn from_env() -> Result<Self, String> {
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
database_url_from_parts(
&env_or("DATABASE_ENGINE", "postgres"),
&env_or("DATABASE_USER", "tavolo"),
Some(&env_or("DATABASE_PASSWORD", "password")),
&env_or("DATABASE_HOST", "localhost"),
&std::env::var("DATABASE_PORT").unwrap_or_default(),
&env_or("DATABASE_NAME", "tavolo"),
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
)
});
let app_port = env_or("APP_PORT", "8080")
.parse::<u16>()
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
Ok(Self {
database_url,
oidc_providers: parse_oidc_providers()?,
app_host: env_or("APP_HOST", "0.0.0.0"),
app_port,
redis_url: env("REDIS_URL"),
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
.parse()
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
static_dir: env_or("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
.parse()
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
logging_config: env("LOGGING_CONFIG"),
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
cors_max_age: env_or("CORS_MAX_AGE", "600")
.parse()
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
otel_enabled: env_bool("OTEL_ENABLED", false),
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_headers: otel_headers,
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
.unwrap_or_else(|| vec!["/api/health".to_string()]),
otel_metric_export_interval_millis: env_or(
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"60000",
)
.parse()
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
.parse()
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
})
}
}