use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeployMode {
#[default]
SelfHosted,
Cloud,
}
impl DeployMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::SelfHosted => "self_hosted",
Self::Cloud => "cloud",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(value: &str) -> Self {
match value.to_lowercase().replace('-', "_").as_str() {
"self_hosted" | "selfhosted" | "docker" | "local" | "on_prem" | "on_premise" => {
Self::SelfHosted
}
"cloud" | "managed" | "saas" | "hosted" => Self::Cloud,
_ => Self::SelfHosted,
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct BackendDeployConfig {
pub mode: DeployMode,
pub dsn: Option<String>,
pub tls_override: Option<bool>,
pub label: String,
}
impl std::fmt::Debug for BackendDeployConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackendDeployConfig")
.field("mode", &self.mode)
.field("dsn", &self.dsn.as_ref().map(|_| "[redacted]"))
.field("tls_override", &self.tls_override)
.field("label", &self.label)
.finish()
}
}
#[cfg(test)]
mod secret_no_leak {
use super::*;
#[test]
fn backend_deploy_config_debug_redacts_dsn() {
let canary = "udb-canary-SECRET";
let cfg = BackendDeployConfig {
dsn: Some(format!("postgres://u:{canary}@h/db")),
label: "primary".to_string(),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains(canary),
"BackendDeployConfig leaked a secret: {dbg}"
);
assert!(dbg.contains("primary"), "non-secret label was dropped");
}
}
impl BackendDeployConfig {
pub fn tls_required(&self) -> bool {
match self.tls_override {
Some(v) => v,
None => self.mode == DeployMode::Cloud,
}
}
pub fn has_dsn(&self) -> bool {
self.dsn
.as_ref()
.map(|d| !d.trim().is_empty())
.unwrap_or(false)
}
}
pub fn detect_deploy_mode_from_dsn(dsn: &str) -> DeployMode {
let lower = dsn.to_lowercase();
if lower.contains(".neon.tech")
|| lower.contains(".supabase.co")
|| lower.contains(".supabase.com")
|| lower.contains("rds.amazonaws.com")
|| lower.contains("cloudsql")
|| lower.contains("azure.com")
|| lower.contains("planetscale.com")
|| lower.contains("cockroachlabs.cloud")
|| lower.contains("railway.app")
|| lower.contains("fly.io")
{
return DeployMode::Cloud;
}
if lower.contains(".upstash.io")
|| lower.contains(".redislabs.com")
|| lower.contains(".elasticache.amazonaws.com")
{
return DeployMode::Cloud;
}
if lower.contains(".qdrant.io") || lower.contains("cloud.qdrant.io") {
return DeployMode::Cloud;
}
if lower.contains(".amazonaws.com")
|| lower.contains(".blob.core.windows.net")
|| lower.contains(".storage.googleapis.com")
|| lower.contains(".digitaloceanspaces.com")
|| lower.contains(".backblazeb2.com")
{
return DeployMode::Cloud;
}
if lower.contains(".mongodb.net") || lower.contains("atlas.mongodb.com") {
return DeployMode::Cloud;
}
if lower.contains(".databases.neo4j.io") || lower.contains("neo4j+s://") {
return DeployMode::Cloud;
}
if lower.contains(".clickhouse.cloud") {
return DeployMode::Cloud;
}
DeployMode::SelfHosted
}
pub fn resolve_deploy_mode(mode_env_key: &str, dsn_env_keys: &[&str]) -> DeployMode {
if let Ok(val) = std::env::var(mode_env_key)
&& !val.trim().is_empty()
{
return DeployMode::from_str(&val);
}
for key in dsn_env_keys {
if let Ok(dsn) = std::env::var(key)
&& !dsn.trim().is_empty()
{
let mode = detect_deploy_mode_from_dsn(&dsn);
if mode == DeployMode::Cloud {
return mode;
}
}
}
DeployMode::SelfHosted
}
pub fn backend_deploy_config_from_env(
mode_env_key: &str,
dsn_env_keys: &[&str],
label: &str,
) -> BackendDeployConfig {
let dsn = dsn_env_keys
.iter()
.find_map(|key| std::env::var(key).ok().filter(|v| !v.trim().is_empty()));
let mode = resolve_deploy_mode(mode_env_key, dsn_env_keys);
BackendDeployConfig {
mode,
dsn,
tls_override: None,
label: label.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DeployProfile {
pub postgres: BackendDeployConfig,
pub redis: BackendDeployConfig,
pub qdrant: BackendDeployConfig,
pub minio: BackendDeployConfig,
pub mongodb: BackendDeployConfig,
pub neo4j: BackendDeployConfig,
pub clickhouse: BackendDeployConfig,
}
impl DeployProfile {
pub fn from_env() -> Self {
Self {
postgres: backend_deploy_config_from_env(
"UDB_PG_DEPLOY_MODE",
&["UDB_PG_DSN", "DATABASE_URL", "POSTGRES_URL", "POSTGRES_DSN"],
"postgres",
),
redis: backend_deploy_config_from_env(
"UDB_REDIS_DEPLOY_MODE",
&["UDB_REDIS_DSN", "REDIS_URL", "REDIS_DSN"],
"redis",
),
qdrant: backend_deploy_config_from_env(
"UDB_QDRANT_DEPLOY_MODE",
&["UDB_QDRANT_DSN", "QDRANT_URL", "QDRANT_DSN"],
"qdrant",
),
minio: backend_deploy_config_from_env(
"UDB_MINIO_DEPLOY_MODE",
&[
"UDB_MINIO_DSN",
"MINIO_ENDPOINT",
"AWS_ENDPOINT_URL",
"S3_ENDPOINT",
],
"minio",
),
mongodb: backend_deploy_config_from_env(
"UDB_MONGO_DEPLOY_MODE",
&["UDB_NOSQL_DSN", "MONGODB_URI", "MONGO_DSN"],
"mongodb",
),
neo4j: backend_deploy_config_from_env(
"UDB_NEO4J_DEPLOY_MODE",
&["UDB_GRAPH_DSN", "NEO4J_URI", "NEO4J_DSN"],
"neo4j",
),
clickhouse: backend_deploy_config_from_env(
"UDB_CH_DEPLOY_MODE",
&["UDB_COLUMN_DSN", "CLICKHOUSE_URL", "CLICKHOUSE_DSN"],
"clickhouse",
),
}
}
pub fn summary(&self) -> serde_json::Value {
serde_json::json!({
"postgres": { "mode": self.postgres.mode.as_str(), "tls": self.postgres.tls_required(), "dsn_set": self.postgres.has_dsn() },
"redis": { "mode": self.redis.mode.as_str(), "tls": self.redis.tls_required(), "dsn_set": self.redis.has_dsn() },
"qdrant": { "mode": self.qdrant.mode.as_str(), "tls": self.qdrant.tls_required(), "dsn_set": self.qdrant.has_dsn() },
"minio": { "mode": self.minio.mode.as_str(), "tls": self.minio.tls_required(), "dsn_set": self.minio.has_dsn() },
"mongodb": { "mode": self.mongodb.mode.as_str(), "tls": self.mongodb.tls_required(), "dsn_set": self.mongodb.has_dsn() },
"neo4j": { "mode": self.neo4j.mode.as_str(), "tls": self.neo4j.tls_required(), "dsn_set": self.neo4j.has_dsn() },
"clickhouse": { "mode": self.clickhouse.mode.as_str(), "tls": self.clickhouse.tls_required(), "dsn_set": self.clickhouse.has_dsn() },
})
}
pub fn is_uniform(&self) -> bool {
let modes = [
&self.postgres.mode,
&self.redis.mode,
&self.qdrant.mode,
&self.minio.mode,
&self.mongodb.mode,
&self.neo4j.mode,
&self.clickhouse.mode,
];
let first = modes[0];
modes.iter().all(|m| *m == first)
}
pub fn cloud_backends(&self) -> Vec<&str> {
let mut out = Vec::new();
if self.postgres.mode == DeployMode::Cloud {
out.push("postgres");
}
if self.redis.mode == DeployMode::Cloud {
out.push("redis");
}
if self.qdrant.mode == DeployMode::Cloud {
out.push("qdrant");
}
if self.minio.mode == DeployMode::Cloud {
out.push("minio");
}
if self.mongodb.mode == DeployMode::Cloud {
out.push("mongodb");
}
if self.neo4j.mode == DeployMode::Cloud {
out.push("neo4j");
}
if self.clickhouse.mode == DeployMode::Cloud {
out.push("clickhouse");
}
out
}
pub fn self_hosted_backends(&self) -> Vec<&str> {
let mut out = Vec::new();
if self.postgres.mode == DeployMode::SelfHosted {
out.push("postgres");
}
if self.redis.mode == DeployMode::SelfHosted {
out.push("redis");
}
if self.qdrant.mode == DeployMode::SelfHosted {
out.push("qdrant");
}
if self.minio.mode == DeployMode::SelfHosted {
out.push("minio");
}
if self.mongodb.mode == DeployMode::SelfHosted {
out.push("mongodb");
}
if self.neo4j.mode == DeployMode::SelfHosted {
out.push("neo4j");
}
if self.clickhouse.mode == DeployMode::SelfHosted {
out.push("clickhouse");
}
out
}
}