use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct EnvValue {
pub env: String,
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum ConfigValue {
Value(String),
Environment(EnvValue),
}
impl ConfigValue {
pub fn value(&self) -> Option<String> {
match self {
ConfigValue::Value(value) => Some(value.clone()),
ConfigValue::Environment(env) => std::env::var(&env.env).ok(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub backend: Backend,
#[serde(default)]
pub encryption: Option<EncryptionConfig>,
#[serde(default)]
pub upstream: Option<UpstreamConfig>,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub local_storage: LocalStorageConfig,
#[serde(default)]
pub managed_storage_root: Option<String>,
#[serde(default)]
pub ui: UiConfig,
}
pub const DEFAULT_HOST: &str = "0.0.0.0";
pub const DEFAULT_PORT: u16 = 8080;
impl Config {
pub fn load(path: Option<&String>) -> Result<Self, String> {
let Some(path) = path else {
return Ok(Config::default());
};
let p = std::path::Path::new(path);
if !p.exists() {
tracing::info!("config file not found at {}, using defaults", p.display());
return Ok(Config::default());
}
let contents =
std::fs::read_to_string(p).map_err(|e| format!("reading config `{path}`: {e}"))?;
serde_yml::from_str(&contents).map_err(|e| format!("parsing config `{path}`: {e}"))
}
pub fn resolved_host(&self) -> &str {
self.host.as_deref().unwrap_or(DEFAULT_HOST)
}
pub fn resolved_port(&self) -> u16 {
self.port.unwrap_or(DEFAULT_PORT)
}
pub fn health_url(&self) -> String {
let host = match self.resolved_host() {
"0.0.0.0" | "" | "::" => "127.0.0.1",
other => other,
};
format!("http://{host}:{}/health", self.resolved_port())
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(default)]
pub struct UiConfig {
pub base_path: String,
pub serve: bool,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
base_path: String::new(),
serve: true,
}
}
}
impl UiConfig {
pub fn normalized_base_path(&self) -> String {
let trimmed = self.base_path.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
}
#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct LocalStorageConfig {
#[serde(default)]
pub allowed_roots: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
host: None,
port: None,
backend: Backend::default(),
encryption: Some(EncryptionConfig::dev_default()),
upstream: None,
routing: RoutingConfig::default(),
local_storage: LocalStorageConfig::default(),
managed_storage_root: None,
ui: UiConfig::default(),
}
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct UpstreamConfig {
pub url: String,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RoutingMode {
#[default]
Local,
Upstream,
}
#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct RoutingConfig {
#[serde(default)]
pub catalogs: RoutingMode,
#[serde(default)]
pub schemas: RoutingMode,
#[serde(default)]
pub tables: RoutingMode,
#[serde(default)]
pub credentials: RoutingMode,
#[serde(default)]
pub external_locations: RoutingMode,
#[serde(default)]
pub functions: RoutingMode,
#[serde(default)]
pub recipients: RoutingMode,
#[serde(default)]
pub shares: RoutingMode,
}
impl RoutingConfig {
pub fn any_upstream(&self) -> bool {
self.surfaces()
.iter()
.any(|(_, mode)| *mode == RoutingMode::Upstream)
}
pub fn upstream_surfaces(&self) -> Vec<&'static str> {
self.surfaces()
.into_iter()
.filter(|(_, mode)| *mode == RoutingMode::Upstream)
.map(|(name, _)| name)
.collect()
}
pub fn unsupported_upstream(&self) -> Vec<&'static str> {
[
("credentials", self.credentials),
("external-locations", self.external_locations),
("functions", self.functions),
("recipients", self.recipients),
("shares", self.shares),
]
.into_iter()
.filter(|(_, mode)| *mode == RoutingMode::Upstream)
.map(|(name, _)| name)
.collect()
}
fn surfaces(&self) -> [(&'static str, RoutingMode); 8] {
[
("catalogs", self.catalogs),
("schemas", self.schemas),
("tables", self.tables),
("credentials", self.credentials),
("external-locations", self.external_locations),
("functions", self.functions),
("recipients", self.recipients),
("shares", self.shares),
]
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", tag = "engine")]
pub enum Backend {
Postgres(PostgresBackendConfig),
Sqlite(SqliteBackendConfig),
}
impl Backend {
pub fn is_ephemeral(&self) -> bool {
matches!(
self,
Backend::Sqlite(cfg) if cfg.database_path().as_deref() == Some(":memory:")
)
}
}
impl Default for Backend {
fn default() -> Self {
Backend::Sqlite(SqliteBackendConfig {
path: ConfigValue::Value(":memory:".to_string()),
})
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SqliteBackendConfig {
pub path: ConfigValue,
}
impl SqliteBackendConfig {
pub fn database_path(&self) -> Option<String> {
self.path.value()
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PostgresBackendConfig {
pub host: ConfigValue,
pub port: ConfigValue,
pub user: ConfigValue,
pub password: ConfigValue,
pub database: ConfigValue,
}
impl PostgresBackendConfig {
pub fn connection_string(&self) -> Option<String> {
let host = self.host.value()?;
let port = self.port.value()?;
let user = self.user.value()?;
let password = self.password.value()?;
let database = self.database.value()?;
Some(format!(
"postgres://{user}:{password}@{host}:{port}/{database}"
))
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct EncryptionConfig {
pub active: KeyConfig,
#[serde(default)]
pub retired: Vec<KeyConfig>,
}
impl EncryptionConfig {
pub fn dev_default() -> Self {
const DEV_KEK: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
Self {
active: KeyConfig {
id: "dev".to_string(),
key: ConfigValue::Value(DEV_KEK.to_string()),
},
retired: Vec::new(),
}
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct KeyConfig {
pub id: String,
pub key: ConfigValue,
}
impl EncryptionConfig {
pub fn build_encryptor(
&self,
) -> Result<unitycatalog_common::services::encryption::EnvelopeEncryptor, String> {
use base64::Engine as _;
use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
let mut keys = Vec::new();
for key in std::iter::once(&self.active).chain(self.retired.iter()) {
let encoded = key
.key
.value()
.ok_or_else(|| format!("KEK '{}' material could not be resolved", key.id))?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|e| format!("KEK '{}' is not valid base64: {e}", key.id))?;
keys.push((key.id.clone(), bytes));
}
let provider = LocalKeyProvider::new(self.active.id.clone(), keys)
.map_err(|e| format!("invalid encryption configuration: {e}"))?;
Ok(EnvelopeEncryptor::local(provider))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_config() {
let config = r#"
{
"host": "0.0.0.0",
"port": 8080,
"backend": {
"engine": "postgres",
"database": "postgres",
"host": "localhost",
"port": "5432",
"user": "user",
"password": {
"env": "PG_PASSWORD"
}
}
}
"#;
let config: Config = serde_json::from_str(config).unwrap();
assert_eq!(config.host.as_deref(), Some("0.0.0.0"));
assert_eq!(config.port, Some(8080));
assert!(matches!(config.backend, Backend::Postgres(_)));
let backend = match config.backend {
Backend::Postgres(backend) => backend,
_ => unreachable!(),
};
assert_eq!(backend.host.value().unwrap(), "localhost");
assert_eq!(backend.port.value().unwrap(), "5432");
assert_eq!(backend.user.value().unwrap(), "user");
assert_eq!(
backend.password,
ConfigValue::Environment(EnvValue {
env: "PG_PASSWORD".to_string()
})
);
}
#[test]
fn test_deserialize_sqlite_config() {
let config = r#"
backend:
engine: sqlite
path: /var/lib/unitycatalog/catalog.db
"#;
let config: Config = serde_yml::from_str(config).unwrap();
let backend = match config.backend {
Backend::Sqlite(backend) => backend,
other => panic!("expected sqlite backend, got {other:?}"),
};
assert_eq!(
backend.database_path().as_deref(),
Some("/var/lib/unitycatalog/catalog.db")
);
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.host.is_none());
assert!(config.port.is_none());
let backend = match config.backend {
Backend::Sqlite(ref b) => b,
ref other => panic!("expected sqlite backend, got {other:?}"),
};
assert_eq!(backend.database_path().as_deref(), Some(":memory:"));
assert!(config.upstream.is_none());
assert_eq!(config.routing, RoutingConfig::default());
assert!(!config.routing.any_upstream());
let enc = config.encryption.as_ref().expect("dev encryption present");
assert_eq!(enc.active.id, "dev");
assert!(enc.build_encryptor().is_ok());
}
#[test]
fn test_minimal_config() {
let config = r#"{}"#;
let config: Config = serde_json::from_str(config).unwrap();
assert!(matches!(config.backend, Backend::Sqlite(_)));
assert!(!config.routing.any_upstream());
}
#[test]
fn test_encryption_config_builds_encryptor() {
use base64::Engine as _;
let active = base64::engine::general_purpose::STANDARD.encode([0x11u8; 32]);
let retired = base64::engine::general_purpose::STANDARD.encode([0x22u8; 32]);
let yaml = format!(
r#"
backend:
engine: sqlite
path: ":memory:"
encryption:
active:
id: v2
key: "{active}"
retired:
- id: v1
key: "{retired}"
"#
);
let config: Config = serde_yml::from_str(&yaml).unwrap();
let enc = config.encryption.as_ref().expect("encryption present");
assert_eq!(enc.active.id, "v2");
assert_eq!(enc.retired.len(), 1);
assert!(enc.build_encryptor().is_ok());
let reparsed: Config =
serde_yml::from_str(&serde_yml::to_string(&config).unwrap()).unwrap();
assert_eq!(reparsed.encryption, config.encryption);
}
#[test]
fn test_encryption_config_rejects_bad_key() {
let yaml = r#"
encryption:
active:
id: v1
key: "not-base64-and-wrong-size!!"
"#;
let config: Config = serde_yml::from_str(yaml).unwrap();
assert!(config.encryption.unwrap().build_encryptor().is_err());
}
#[test]
fn test_managed_storage_root_roundtrips() {
let yaml = r#"
backend:
engine: sqlite
path: ":memory:"
managed_storage_root: "s3://bucket/meta"
"#;
let config: Config = serde_yml::from_str(yaml).unwrap();
assert_eq!(
config.managed_storage_root.as_deref(),
Some("s3://bucket/meta")
);
let reparsed: Config =
serde_yml::from_str(&serde_yml::to_string(&config).unwrap()).unwrap();
assert_eq!(reparsed.managed_storage_root, config.managed_storage_root);
let bare: Config =
serde_yml::from_str("backend:\n engine: sqlite\n path: \":memory:\"\n").unwrap();
assert!(bare.managed_storage_root.is_none());
}
#[test]
fn test_backend_is_ephemeral() {
assert!(Config::default().backend.is_ephemeral());
let mem: Config =
serde_yml::from_str("backend:\n engine: sqlite\n path: \":memory:\"\n").unwrap();
assert!(mem.backend.is_ephemeral());
let file: Config =
serde_yml::from_str("backend:\n engine: sqlite\n path: /tmp/uc.db\n").unwrap();
assert!(!file.backend.is_ephemeral());
}
#[test]
fn test_ui_config_defaults_and_normalization() {
let ui = UiConfig::default();
assert!(ui.serve);
assert_eq!(ui.normalized_base_path(), "");
for raw in ["catalog", "/catalog", "/catalog/", " catalog/ "] {
let ui = UiConfig {
base_path: raw.to_string(),
serve: true,
};
assert_eq!(ui.normalized_base_path(), "/catalog", "input {raw:?}");
}
}
#[test]
fn test_health_url_maps_wildcard_host_to_loopback() {
let mut cfg = Config {
host: Some("0.0.0.0".into()),
port: Some(9000),
..Config::default()
};
assert_eq!(cfg.health_url(), "http://127.0.0.1:9000/health");
cfg.host = Some("example.test".into());
assert_eq!(cfg.health_url(), "http://example.test:9000/health");
}
#[test]
fn test_routing_defaults_to_local() {
let routing = RoutingConfig::default();
assert_eq!(routing.catalogs, RoutingMode::Local);
assert!(!routing.any_upstream());
assert!(routing.unsupported_upstream().is_empty());
}
#[test]
fn test_hybrid_config_roundtrip() {
let yaml = r#"
backend:
engine: sqlite
path: ":memory:"
upstream:
url: "http://uc-java:8080/api/2.1/unity-catalog"
routing:
catalogs: upstream
schemas: local
tables: upstream
"#;
let config: Config = serde_yml::from_str(yaml).unwrap();
assert_eq!(
config.upstream,
Some(UpstreamConfig {
url: "http://uc-java:8080/api/2.1/unity-catalog".to_string(),
})
);
assert_eq!(config.routing.catalogs, RoutingMode::Upstream);
assert_eq!(config.routing.schemas, RoutingMode::Local);
assert_eq!(config.routing.tables, RoutingMode::Upstream);
assert!(config.routing.any_upstream());
assert!(config.routing.unsupported_upstream().is_empty());
let serialized = serde_yml::to_string(&config).unwrap();
let reparsed: Config = serde_yml::from_str(&serialized).unwrap();
assert_eq!(reparsed.routing, config.routing);
assert_eq!(reparsed.upstream, config.upstream);
}
#[test]
fn test_unsupported_upstream_surfaces_detected() {
let yaml = r#"
routing:
catalogs: upstream
functions: upstream
shares: upstream
"#;
let config: Config = serde_yml::from_str(yaml).unwrap();
assert!(config.routing.any_upstream());
assert_eq!(
config.routing.unsupported_upstream(),
vec!["functions", "shares"]
);
}
}