use std::collections::HashSet;
use std::env;
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use serde_json::Value;
use tokio::sync::Mutex;
use crate::client::ConfigClient;
use crate::schema::ConfigDefinition;
use crate::token_provider::TokenProvider;
use crate::utils::camel_to_upper_snake;
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30);
pub const DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigTier {
Blob,
Env,
Http,
File,
}
impl ConfigTier {
pub fn as_str(self) -> &'static str {
match self {
ConfigTier::Blob => "blob",
ConfigTier::Env => "env",
ConfigTier::Http => "http",
ConfigTier::File => "file",
}
}
}
impl fmt::Display for ConfigTier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigBootstrapError {
pub missing: Vec<String>,
}
impl fmt::Display for ConfigBootstrapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let vars = if self.missing.len() == 1 {
"this variable"
} else {
"these variables"
};
write!(
f,
"[smooai-config] container-mode bootstrap failed: missing required env {}. \
Set {} before calling init_container_config() \
(see docs/Container-Runtime-Mode.md for the Kubernetes/ExternalSecret recipe).",
self.missing.join(", "),
vars,
)
}
}
impl std::error::Error for ConfigBootstrapError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigKeyUnresolvedError {
pub key: String,
pub env: String,
pub tried_tiers: Vec<ConfigTier>,
}
impl fmt::Display for ConfigKeyUnresolvedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let tiers: Vec<&str> = self.tried_tiers.iter().map(|t| t.as_str()).collect();
let tiers = if tiers.is_empty() {
"none".to_string()
} else {
tiers.join(" → ")
};
write!(
f,
"[smooai-config] required config key \"{}\" did not resolve in environment \"{}\" \
(container mode; tiers tried: {}). \
Set a value for this key in the config server for \"{}\", or mark it optional via \
init_container_config(optional_keys: [\"{}\"]).",
self.key, self.env, tiers, self.env, self.key,
)
}
}
impl std::error::Error for ConfigKeyUnresolvedError {}
#[derive(Debug)]
pub enum ConfigError {
Bootstrap(ConfigBootstrapError),
KeyUnresolved(ConfigKeyUnresolvedError),
Fetch(String),
}
impl ConfigError {
pub fn key_unresolved(key: impl Into<String>, env: impl Into<String>, tried_tiers: Vec<ConfigTier>) -> Self {
ConfigError::KeyUnresolved(ConfigKeyUnresolvedError {
key: key.into(),
env: env.into(),
tried_tiers,
})
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Bootstrap(e) => fmt::Display::fmt(e, f),
ConfigError::KeyUnresolved(e) => fmt::Display::fmt(e, f),
ConfigError::Fetch(msg) => write!(f, "[smooai-config] container config fetch failed: {msg}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::Bootstrap(e) => Some(e),
ConfigError::KeyUnresolved(e) => Some(e),
ConfigError::Fetch(_) => None,
}
}
}
impl From<ConfigBootstrapError> for ConfigError {
fn from(e: ConfigBootstrapError) -> Self {
ConfigError::Bootstrap(e)
}
}
impl From<ConfigKeyUnresolvedError> for ConfigError {
fn from(e: ConfigKeyUnresolvedError) -> Self {
ConfigError::KeyUnresolved(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigHealth {
Healthy,
Unhealthy {
reason: String,
},
}
impl ConfigHealth {
pub fn status(&self) -> &'static str {
match self {
ConfigHealth::Healthy => "healthy",
ConfigHealth::Unhealthy { .. } => "unhealthy",
}
}
pub fn is_healthy(&self) -> bool {
matches!(self, ConfigHealth::Healthy)
}
}
#[derive(Default)]
pub struct InitContainerConfigOptions {
pub schema: ConfigDefinition,
pub api_url: Option<String>,
pub auth_url: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub org_id: Option<String>,
pub environment: Option<String>,
pub cache_ttl: Option<Duration>,
pub token_refresh_buffer_seconds: Option<u64>,
pub optional_keys: Vec<String>,
pub config_client: Option<ConfigClient>,
}
fn non_blank(v: Option<String>) -> Option<String> {
v.and_then(|s| if s.trim().is_empty() { None } else { Some(s) })
}
fn env_var(name: &str) -> Option<String> {
non_blank(env::var(name).ok())
}
struct ResolvedContainerEnv {
api_url: String,
auth_url: String,
client_id: String,
client_secret: String,
org_id: String,
environment: String,
}
fn resolve_and_validate_env(
options: &InitContainerConfigOptions,
) -> Result<ResolvedContainerEnv, ConfigBootstrapError> {
let api_url = non_blank(options.api_url.clone()).or_else(|| env_var("SMOOAI_CONFIG_API_URL"));
let auth_url = non_blank(options.auth_url.clone())
.or_else(|| env_var("SMOOAI_CONFIG_AUTH_URL"))
.or_else(|| env_var("SMOOAI_AUTH_URL"))
.unwrap_or_else(|| "https://auth.smoo.ai".to_string());
let client_id = non_blank(options.client_id.clone()).or_else(|| env_var("SMOOAI_CONFIG_CLIENT_ID"));
let client_secret = non_blank(options.client_secret.clone())
.or_else(|| env_var("SMOOAI_CONFIG_CLIENT_SECRET"))
.or_else(|| env_var("SMOOAI_CONFIG_API_KEY"));
let org_id = non_blank(options.org_id.clone()).or_else(|| env_var("SMOOAI_CONFIG_ORG_ID"));
let environment = non_blank(options.environment.clone()).or_else(|| env_var("SMOOAI_CONFIG_ENV"));
let client_injected = options.config_client.is_some();
let mut missing: Vec<String> = Vec::new();
if !client_injected {
if api_url.is_none() {
missing.push("SMOOAI_CONFIG_API_URL".to_string());
}
if client_id.is_none() {
missing.push("SMOOAI_CONFIG_CLIENT_ID".to_string());
}
if client_secret.is_none() {
missing.push("SMOOAI_CONFIG_CLIENT_SECRET".to_string());
}
if org_id.is_none() {
missing.push("SMOOAI_CONFIG_ORG_ID".to_string());
}
}
if environment.is_none() {
missing.push("SMOOAI_CONFIG_ENV".to_string());
}
if !missing.is_empty() {
return Err(ConfigBootstrapError { missing });
}
Ok(ResolvedContainerEnv {
api_url: api_url.unwrap_or_default(),
auth_url,
client_id: client_id.unwrap_or_default(),
client_secret: client_secret.unwrap_or_default(),
org_id: org_id.unwrap_or_default(),
environment: environment.expect("environment validated present"),
})
}
struct HealthState {
last_fetch_ok: bool,
last_fetch_at: Option<Instant>,
last_error: Option<String>,
}
struct SyncCacheEntry {
value: Value,
expires_at: Option<Instant>,
}
struct Inner {
client: Mutex<ConfigClient>,
sync_cache: RwLock<std::collections::HashMap<String, SyncCacheEntry>>,
environment: String,
cache_ttl: Duration,
optional_keys: HashSet<String>,
health: std::sync::Mutex<HealthState>,
}
impl Inner {
fn is_optional(&self, key: &str) -> bool {
self.optional_keys.contains(key)
}
fn record_ok(&self) {
let mut h = self.health.lock().expect("health mutex");
h.last_fetch_ok = true;
h.last_fetch_at = Some(Instant::now());
h.last_error = None;
}
fn record_err(&self, msg: String) {
let mut h = self.health.lock().expect("health mutex");
h.last_error = Some(msg);
}
fn health(&self) -> ConfigHealth {
let h = self.health.lock().expect("health mutex");
if !h.last_fetch_ok {
return ConfigHealth::Unhealthy {
reason: h
.last_error
.clone()
.unwrap_or_else(|| "initial config fetch has not succeeded".to_string()),
};
}
if let (Some(err), Some(at)) = (h.last_error.as_ref(), h.last_fetch_at) {
if at.elapsed() > self.cache_ttl {
return ConfigHealth::Unhealthy {
reason: format!(
"last config refresh failed and cache TTL ({:?}) expired: {err}",
self.cache_ttl
),
};
}
}
ConfigHealth::Healthy
}
fn sync_cached(&self, key: &str) -> Option<Value> {
let guard = self.sync_cache.read().expect("sync cache read");
let entry = guard.get(key)?;
if let Some(expires_at) = entry.expires_at {
if Instant::now() > expires_at {
return None;
}
}
Some(entry.value.clone())
}
fn seed_sync(&self, key: &str, value: Value) {
let expires_at = Some(Instant::now() + self.cache_ttl);
self.sync_cache
.write()
.expect("sync cache write")
.insert(key.to_string(), SyncCacheEntry { value, expires_at });
}
async fn resolve(&self, key: &str) -> (Option<Value>, Vec<ConfigTier>) {
let mut tried = vec![ConfigTier::Env];
if let Some(from_env) = env_var(&camel_to_upper_snake(key)) {
let value = Value::String(from_env);
{
let mut client = self.client.lock().await;
client.seed_cache(key, value.clone(), Some(&self.environment));
}
self.seed_sync(key, value.clone());
return (Some(value), tried);
}
tried.push(ConfigTier::Http);
let result = {
let mut client = self.client.lock().await;
client.get_value(key, Some(&self.environment)).await
};
match result {
Ok(value) => {
self.record_ok();
if is_present(&value) {
self.seed_sync(key, value.clone());
(Some(value), tried)
} else {
(None, tried)
}
}
Err(err) => {
self.record_err(err.to_string());
let cached = {
let client = self.client.lock().await;
client.get_cached_value(key, Some(&self.environment))
};
match cached.filter(is_present) {
Some(value) => {
self.seed_sync(key, value.clone());
(Some(value), tried)
}
None => (None, tried),
}
}
}
}
fn sync_resolve(&self, key: &str) -> (Option<Value>, Vec<ConfigTier>) {
let mut tried = vec![ConfigTier::Env];
if let Some(from_env) = env_var(&camel_to_upper_snake(key)) {
return (Some(Value::String(from_env)), tried);
}
tried.push(ConfigTier::Http);
(self.sync_cached(key).filter(is_present), tried)
}
async fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
let (value, tried) = self.resolve(key).await;
match value {
Some(v) => Ok(Some(v)),
None => {
if self.is_optional(key) {
Ok(None)
} else {
Err(ConfigError::key_unresolved(key, &self.environment, tried))
}
}
}
}
fn get_sync(&self, key: &str) -> Result<Option<Value>, ConfigError> {
let (value, tried) = self.sync_resolve(key);
match value {
Some(v) => Ok(Some(v)),
None => {
if self.is_optional(key) {
Ok(None)
} else {
Err(ConfigError::key_unresolved(key, &self.environment, tried))
}
}
}
}
}
fn is_present(v: &Value) -> bool {
!v.is_null()
}
#[derive(Clone)]
pub struct ContainerConfigHandle {
inner: Arc<Inner>,
}
impl fmt::Debug for ContainerConfigHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContainerConfigHandle")
.field("environment", &self.inner.environment)
.field("cache_ttl", &self.inner.cache_ttl)
.field("health", &self.inner.health())
.finish_non_exhaustive()
}
}
impl ContainerConfigHandle {
pub fn secret_config(&self) -> SecretConfigAccessor<'_> {
SecretConfigAccessor { inner: &self.inner }
}
pub fn public_config(&self) -> PublicConfigAccessor<'_> {
PublicConfigAccessor { inner: &self.inner }
}
pub fn feature_flag(&self) -> FeatureFlagAccessor<'_> {
FeatureFlagAccessor { inner: &self.inner }
}
pub fn health(&self) -> ConfigHealth {
self.inner.health()
}
pub async fn with_client<R>(&self, f: impl FnOnce(&mut ConfigClient) -> R) -> R {
let mut client = self.inner.client.lock().await;
f(&mut client)
}
}
macro_rules! tier_accessor {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
pub struct $name<'a> {
inner: &'a Inner,
}
impl $name<'_> {
pub async fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
self.inner.get(key).await
}
pub fn get_sync(&self, key: &str) -> Result<Option<Value>, ConfigError> {
self.inner.get_sync(key)
}
}
};
}
tier_accessor!(
SecretConfigAccessor
);
tier_accessor!(
PublicConfigAccessor
);
tier_accessor!(
FeatureFlagAccessor
);
pub async fn init_container_config(options: InitContainerConfigOptions) -> Result<ContainerConfigHandle, ConfigError> {
let env = resolve_and_validate_env(&options)?;
let cache_ttl = options.cache_ttl.unwrap_or(DEFAULT_CACHE_TTL);
let refresh_buffer = options
.token_refresh_buffer_seconds
.unwrap_or(DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS);
let optional_keys: HashSet<String> = options.optional_keys.iter().cloned().collect();
let mut client = match options.config_client {
Some(c) => c,
None => {
let provider = TokenProvider::with_options(
&env.auth_url,
&env.client_id,
&env.client_secret,
Duration::from_secs(refresh_buffer),
reqwest::Client::new(),
)
.map_err(|e| ConfigError::Fetch(e.to_string()))?;
ConfigClient::with_token_provider(&env.api_url, Arc::new(provider), &env.org_id, &env.environment)
}
};
client.set_cache_ttl(Some(cache_ttl));
let initial = client.get_all_values(Some(&env.environment)).await;
let mut sync_cache = std::collections::HashMap::new();
let seeded_expires_at = Some(Instant::now() + cache_ttl);
let health = match initial {
Ok(values) => {
for (k, v) in values {
if is_present(&v) {
sync_cache.insert(
k,
SyncCacheEntry {
value: v,
expires_at: seeded_expires_at,
},
);
}
}
HealthState {
last_fetch_ok: true,
last_fetch_at: Some(Instant::now()),
last_error: None,
}
}
Err(err) => {
return Err(ConfigError::Fetch(err.to_string()));
}
};
let _ = &options.schema;
let inner = Arc::new(Inner {
client: Mutex::new(client),
sync_cache: RwLock::new(sync_cache),
environment: env.environment,
cache_ttl,
optional_keys,
health: std::sync::Mutex::new(health),
});
Ok(ContainerConfigHandle { inner })
}
pub fn config_health(handle: &ContainerConfigHandle) -> ConfigHealth {
handle.health()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Container,
Default,
}
#[derive(Default)]
pub struct SelectModeInputs {
pub mode: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub api_url: Option<String>,
pub blob_present: Option<bool>,
pub file_present: Option<bool>,
}
static AUTO_SELECT_LOGGED: AtomicBool = AtomicBool::new(false);
pub fn select_mode(inputs: Option<SelectModeInputs>) -> Mode {
let inputs = inputs.unwrap_or_default();
let mode = non_blank(inputs.mode).or_else(|| env_var("SMOOAI_CONFIG_MODE"));
if mode
.as_deref()
.map(|m| m.eq_ignore_ascii_case("container"))
.unwrap_or(false)
{
return Mode::Container;
}
let blob_present = inputs
.blob_present
.unwrap_or_else(|| env_var("SMOO_CONFIG_KEY").is_some() && env_var("SMOO_CONFIG_KEY_FILE").is_some());
let file_present = inputs.file_present.unwrap_or(false);
if blob_present || file_present {
return Mode::Default;
}
let client_id = non_blank(inputs.client_id).or_else(|| env_var("SMOOAI_CONFIG_CLIENT_ID"));
let client_secret = non_blank(inputs.client_secret)
.or_else(|| env_var("SMOOAI_CONFIG_CLIENT_SECRET"))
.or_else(|| env_var("SMOOAI_CONFIG_API_KEY"));
let api_url = non_blank(inputs.api_url).or_else(|| env_var("SMOOAI_CONFIG_API_URL"));
if client_id.is_some() && client_secret.is_some() && api_url.is_some() {
if !AUTO_SELECT_LOGGED.swap(true, Ordering::Relaxed) {
eprintln!(
"[smooai-config] container mode auto-selected \
(CLIENT_ID + CLIENT_SECRET + API_URL set, no blob/file source present)"
);
}
return Mode::Container;
}
Mode::Default
}
#[doc(hidden)]
pub fn __reset_select_mode_log_for_tests() {
AUTO_SELECT_LOGGED.store(false, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_blank_treats_whitespace_as_absent() {
assert_eq!(non_blank(Some(" ".to_string())), None);
assert_eq!(non_blank(Some("".to_string())), None);
assert_eq!(non_blank(Some("x".to_string())), Some("x".to_string()));
assert_eq!(non_blank(None), None);
}
#[test]
fn tier_strings_match_wire_contract() {
assert_eq!(ConfigTier::Blob.as_str(), "blob");
assert_eq!(ConfigTier::Env.as_str(), "env");
assert_eq!(ConfigTier::Http.as_str(), "http");
assert_eq!(ConfigTier::File.as_str(), "file");
}
#[test]
fn bootstrap_error_message_lists_vars() {
let e = ConfigBootstrapError {
missing: vec!["SMOOAI_CONFIG_API_URL".to_string(), "SMOOAI_CONFIG_ENV".to_string()],
};
let msg = e.to_string();
assert!(msg.contains("SMOOAI_CONFIG_API_URL"));
assert!(msg.contains("SMOOAI_CONFIG_ENV"));
assert!(msg.contains("these variables"));
}
#[test]
fn bootstrap_error_singular_phrasing() {
let e = ConfigBootstrapError {
missing: vec!["SMOOAI_CONFIG_ENV".to_string()],
};
assert!(e.to_string().contains("this variable"));
}
#[test]
fn key_unresolved_message_carries_context() {
let e = ConfigKeyUnresolvedError {
key: "stripeApiKey".to_string(),
env: "production".to_string(),
tried_tiers: vec![ConfigTier::Env, ConfigTier::Http],
};
let msg = e.to_string();
assert!(msg.contains("stripeApiKey"));
assert!(msg.contains("production"));
assert!(msg.contains("env → http"));
assert!(msg.contains("optional"));
}
#[test]
fn config_error_wraps_typed_variants_as_source() {
let bootstrap: ConfigError = ConfigBootstrapError {
missing: vec!["SMOOAI_CONFIG_ENV".to_string()],
}
.into();
assert!(std::error::Error::source(&bootstrap).is_some());
assert!(matches!(bootstrap, ConfigError::Bootstrap(_)));
let unresolved = ConfigError::key_unresolved("k", "production", vec![ConfigTier::Env, ConfigTier::Http]);
match &unresolved {
ConfigError::KeyUnresolved(e) => {
assert_eq!(e.key, "k");
assert_eq!(e.tried_tiers, vec![ConfigTier::Env, ConfigTier::Http]);
}
other => panic!("expected KeyUnresolved, got {other:?}"),
}
}
#[test]
fn config_health_status_and_helpers() {
assert_eq!(ConfigHealth::Healthy.status(), "healthy");
assert!(ConfigHealth::Healthy.is_healthy());
let u = ConfigHealth::Unhealthy {
reason: "x".to_string(),
};
assert_eq!(u.status(), "unhealthy");
assert!(!u.is_healthy());
}
#[test]
fn is_present_only_null_is_absent() {
assert!(!is_present(&Value::Null));
assert!(is_present(&json_str("")));
assert!(is_present(&Value::Bool(false)));
assert!(is_present(&serde_json::json!(0)));
}
#[test]
fn defaults_match_contract() {
assert_eq!(DEFAULT_CACHE_TTL, Duration::from_secs(30));
assert_eq!(DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS, 60);
}
fn json_str(s: &str) -> Value {
Value::String(s.to_string())
}
}