CI / Build and push docker image (push) Successful in 4m20s
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.
309 lines
11 KiB
Rust
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("e(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())?,
|
|
})
|
|
}
|
|
}
|