Skip to main content

smooai_config/
container.rs

1//! Container / runtime mode for `smooai-config` (SMOODEV-1494).
2//!
3//! Rust parity with the TypeScript reference implementation
4//! (`src/container/`, SMOODEV-1490) and the five-language contract in
5//! [`docs/Container-Runtime-Mode-Spec.md`]. Idioms differ; behavior does not.
6//!
7//! # Why
8//!
9//! `smooai-config` resolves values through four tiers: **blob → env → http →
10//! file**. The blob tier (an encrypted bundle baked into a Lambda layer / image
11//! at deploy time, decrypted with a separately-delivered key) is the blessed
12//! path for **Lambda**. It is the *wrong* default for long-lived **containers**
13//! (EKS/ECS): when the per-build blob key isn't delivered to the pod,
14//! resolution silently falls through to the (absent) file tier and returns an
15//! absent value for a required secret (the SMOODEV-1478 CrashLoop outage).
16//!
17//! Container mode makes the **HTTP tier the blessed, first-class path** for
18//! containers, authenticated with an OAuth2 `client_credentials` (M2M) token,
19//! and **fail-loud** so a missing required value is an immediate, typed error
20//! ([`ConfigKeyUnresolvedError`]) — never a silent absent value.
21//!
22//! # Usage
23//!
24//! ```no_run
25//! use smooai_config::container::{init_container_config, InitContainerConfigOptions};
26//! use smooai_config::schema::define_config;
27//!
28//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
29//! let schema = define_config(None, None, None);
30//! // Validates env, mints a token, does an initial fetch — startup fails
31//! // loudly here, not on first read.
32//! let handle = init_container_config(InitContainerConfigOptions {
33//!     schema,
34//!     ..Default::default()
35//! })
36//! .await?;
37//!
38//! // Fail-loud: a required secret that doesn't resolve returns Err.
39//! let stripe_key = handle.secret_config().get("stripeApiKey").await?;
40//!
41//! // Readiness probe handler:
42//! let health = handle.health();
43//! # let _ = (stripe_key, health);
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! # Env contract (§1 — identical across all five SDKs)
49//!
50//! ```text
51//! SMOOAI_CONFIG_MODE          `container` forces this mode (see select_mode).
52//! SMOOAI_CONFIG_API_URL       (required) config API base URL.
53//! SMOOAI_CONFIG_AUTH_URL      OAuth issuer base URL (default https://auth.smoo.ai).
54//! SMOOAI_CONFIG_CLIENT_ID     (required) M2M OAuth client id.
55//! SMOOAI_CONFIG_CLIENT_SECRET (required) M2M OAuth client secret
56//!                             (legacy alias SMOOAI_CONFIG_API_KEY accepted).
57//! SMOOAI_CONFIG_ORG_ID        (required) org id whose config to fetch.
58//! SMOOAI_CONFIG_ENV           (required) environment name (e.g. production).
59//! ```
60
61use std::collections::HashSet;
62use std::env;
63use std::fmt;
64use std::sync::atomic::{AtomicBool, Ordering};
65use std::sync::{Arc, RwLock};
66use std::time::{Duration, Instant};
67
68use serde_json::Value;
69use tokio::sync::Mutex;
70
71use crate::client::ConfigClient;
72use crate::schema::ConfigDefinition;
73use crate::token_provider::TokenProvider;
74use crate::utils::camel_to_upper_snake;
75
76/// Default config-value cache TTL (§5). Same 30s default in every SDK.
77pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30);
78
79/// Default token proactive-refresh window in seconds (§5).
80pub const DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS: u64 = 60;
81
82// ---------------------------------------------------------------------------
83// Resolution tiers
84// ---------------------------------------------------------------------------
85
86/// One of the resolution tiers consulted during a value read.
87///
88/// In container mode only [`Env`](ConfigTier::Env) and [`Http`](ConfigTier::Http)
89/// are active; [`Blob`](ConfigTier::Blob) and [`File`](ConfigTier::File) exist
90/// for parity with the full tier chain and are reported in error context.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ConfigTier {
93    /// The baked-blob tier (Lambda path; disabled in container mode).
94    Blob,
95    /// The process-environment override tier.
96    Env,
97    /// The HTTP config-server tier (the blessed container path).
98    Http,
99    /// The local `.smooai-config/` file tier (disabled in container mode).
100    File,
101}
102
103impl ConfigTier {
104    /// The lowercase wire name (`"blob" | "env" | "http" | "file"`) — matches
105    /// the TS `ConfigTier` string union carried by [`ConfigKeyUnresolvedError`].
106    pub fn as_str(self) -> &'static str {
107        match self {
108            ConfigTier::Blob => "blob",
109            ConfigTier::Env => "env",
110            ConfigTier::Http => "http",
111            ConfigTier::File => "file",
112        }
113    }
114}
115
116impl fmt::Display for ConfigTier {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(self.as_str())
119    }
120}
121
122// ---------------------------------------------------------------------------
123// Typed errors (parity with TS errors.ts — same names, same carried fields)
124// ---------------------------------------------------------------------------
125
126/// Returned by [`init_container_config`] when the container-required
127/// environment (§1) is missing or blank. Carries the exact list of offending
128/// env var names so the operator can fix the deployment without guessing.
129/// No partial init: if any required var is absent, bootstrap fails whole.
130///
131/// Parity: TS `ConfigBootstrapError { missing: string[] }`.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct ConfigBootstrapError {
134    /// Env var names (e.g. `SMOOAI_CONFIG_CLIENT_ID`) that are missing or blank.
135    pub missing: Vec<String>,
136}
137
138impl fmt::Display for ConfigBootstrapError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        let vars = if self.missing.len() == 1 {
141            "this variable"
142        } else {
143            "these variables"
144        };
145        write!(
146            f,
147            "[smooai-config] container-mode bootstrap failed: missing required env {}. \
148             Set {} before calling init_container_config() \
149             (see docs/Container-Runtime-Mode.md for the Kubernetes/ExternalSecret recipe).",
150            self.missing.join(", "),
151            vars,
152        )
153    }
154}
155
156impl std::error::Error for ConfigBootstrapError {}
157
158/// Returned by a required-key read ([`SecretConfigAccessor::get`] / `get_sync`
159/// and the public/flag analogs) in container mode when the value resolves to
160/// absent across every active tier. This is the exact class that closes the
161/// silent-absent-value hole (SMOODEV-1478 / SMOODEV-1135).
162///
163/// Optional keys (declared via [`InitContainerConfigOptions::optional_keys`])
164/// do NOT produce this — they resolve to `Ok(None)`.
165///
166/// Parity: TS `ConfigKeyUnresolvedError { key, env, triedTiers }`.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ConfigKeyUnresolvedError {
169    /// The camelCase config key that could not be resolved.
170    pub key: String,
171    /// The environment the read targeted (e.g. `production`).
172    pub env: String,
173    /// The tiers that were consulted, in order, before giving up
174    /// (container mode tries `["env", "http"]`).
175    pub tried_tiers: Vec<ConfigTier>,
176}
177
178impl fmt::Display for ConfigKeyUnresolvedError {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        let tiers: Vec<&str> = self.tried_tiers.iter().map(|t| t.as_str()).collect();
181        let tiers = if tiers.is_empty() {
182            "none".to_string()
183        } else {
184            tiers.join(" → ")
185        };
186        write!(
187            f,
188            "[smooai-config] required config key \"{}\" did not resolve in environment \"{}\" \
189             (container mode; tiers tried: {}). \
190             Set a value for this key in the config server for \"{}\", or mark it optional via \
191             init_container_config(optional_keys: [\"{}\"]).",
192            self.key, self.env, tiers, self.env, self.key,
193        )
194    }
195}
196
197impl std::error::Error for ConfigKeyUnresolvedError {}
198
199/// Unified error type for container mode. Carries the [`ConfigBootstrapError`]
200/// and [`ConfigKeyUnresolvedError`] variants with their exact fields, plus
201/// auth/network failures surfaced during the initial fetch or a value read.
202#[derive(Debug)]
203pub enum ConfigError {
204    /// Container-required env was missing or blank at bootstrap.
205    Bootstrap(ConfigBootstrapError),
206    /// A required key did not resolve across the active tiers.
207    KeyUnresolved(ConfigKeyUnresolvedError),
208    /// The initial token mint / config fetch failed, or a request errored.
209    /// Carries the underlying message (auth, network, non-2xx status).
210    Fetch(String),
211}
212
213impl ConfigError {
214    /// Convenience constructor for a [`ConfigError::KeyUnresolved`].
215    pub fn key_unresolved(key: impl Into<String>, env: impl Into<String>, tried_tiers: Vec<ConfigTier>) -> Self {
216        ConfigError::KeyUnresolved(ConfigKeyUnresolvedError {
217            key: key.into(),
218            env: env.into(),
219            tried_tiers,
220        })
221    }
222}
223
224impl fmt::Display for ConfigError {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            ConfigError::Bootstrap(e) => fmt::Display::fmt(e, f),
228            ConfigError::KeyUnresolved(e) => fmt::Display::fmt(e, f),
229            ConfigError::Fetch(msg) => write!(f, "[smooai-config] container config fetch failed: {msg}"),
230        }
231    }
232}
233
234impl std::error::Error for ConfigError {
235    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
236        match self {
237            ConfigError::Bootstrap(e) => Some(e),
238            ConfigError::KeyUnresolved(e) => Some(e),
239            ConfigError::Fetch(_) => None,
240        }
241    }
242}
243
244impl From<ConfigBootstrapError> for ConfigError {
245    fn from(e: ConfigBootstrapError) -> Self {
246        ConfigError::Bootstrap(e)
247    }
248}
249
250impl From<ConfigKeyUnresolvedError> for ConfigError {
251    fn from(e: ConfigKeyUnresolvedError) -> Self {
252        ConfigError::KeyUnresolved(e)
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Health
258// ---------------------------------------------------------------------------
259
260/// Status returned by [`ContainerConfigHandle::health`] / [`config_health`].
261/// Never produced by a fallible path — the accessors return it directly.
262///
263/// Parity: TS `{ status: 'healthy' } | { status: 'unhealthy'; reason }`.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum ConfigHealth {
266    /// The active config source is usable (initial fetch succeeded; serving
267    /// fresh or last-good within the cache TTL).
268    Healthy,
269    /// The initial fetch never succeeded, or a refresh has been failing past
270    /// the TTL hard-expiry.
271    Unhealthy {
272        /// Human-readable reason for the unhealthy status.
273        reason: String,
274    },
275}
276
277impl ConfigHealth {
278    /// The `"healthy" | "unhealthy"` status string (matches the TS shape).
279    pub fn status(&self) -> &'static str {
280        match self {
281            ConfigHealth::Healthy => "healthy",
282            ConfigHealth::Unhealthy { .. } => "unhealthy",
283        }
284    }
285
286    /// Whether the status is [`Healthy`](ConfigHealth::Healthy).
287    pub fn is_healthy(&self) -> bool {
288        matches!(self, ConfigHealth::Healthy)
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Options
294// ---------------------------------------------------------------------------
295
296/// Options for [`init_container_config`]. Every field mirrors an env var in the
297/// §1 contract so tests and embedders can construct a handle without touching
298/// the process environment. When a field is `None`, the env var is read.
299///
300/// `schema` is required so the handle knows which keys exist and can apply the
301/// default-required posture (every schema key is required unless listed in
302/// `optional_keys`).
303#[derive(Default)]
304pub struct InitContainerConfigOptions {
305    /// The config schema for this service. Required.
306    pub schema: ConfigDefinition,
307    /// Config API base URL. Falls back to `SMOOAI_CONFIG_API_URL`.
308    pub api_url: Option<String>,
309    /// OAuth issuer base URL. Falls back to `SMOOAI_CONFIG_AUTH_URL`, then
310    /// legacy `SMOOAI_AUTH_URL`, then `https://auth.smoo.ai`.
311    pub auth_url: Option<String>,
312    /// M2M OAuth client id. Falls back to `SMOOAI_CONFIG_CLIENT_ID`.
313    pub client_id: Option<String>,
314    /// M2M OAuth client secret. Falls back to `SMOOAI_CONFIG_CLIENT_SECRET`,
315    /// then legacy `SMOOAI_CONFIG_API_KEY`.
316    pub client_secret: Option<String>,
317    /// Org id whose config to fetch. Falls back to `SMOOAI_CONFIG_ORG_ID`.
318    pub org_id: Option<String>,
319    /// Environment name (e.g. `production`). Falls back to `SMOOAI_CONFIG_ENV`.
320    pub environment: Option<String>,
321    /// Config value cache TTL. Default [`DEFAULT_CACHE_TTL`] (30s). A background
322    /// refresh failure serves the last-good value until this TTL hard-expires,
323    /// at which point [`ContainerConfigHandle::health`] reports unhealthy (§5).
324    pub cache_ttl: Option<Duration>,
325    /// Seconds before token expiry to proactively refresh. Default
326    /// [`DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS`] (60s).
327    pub token_refresh_buffer_seconds: Option<u64>,
328    /// Keys allowed to be absent. A read of any of these returns `Ok(None)`
329    /// instead of a [`ConfigError::KeyUnresolved`]. Everything else declared in
330    /// `schema` is required (container mode's default-required posture).
331    pub optional_keys: Vec<String>,
332    /// Test/embedding seam — inject a pre-built [`ConfigClient`]. When supplied,
333    /// `api_url`/`auth_url`/`client_id`/`client_secret`/`org_id` env validation
334    /// is skipped (the client carries them) but `environment` is still required.
335    pub config_client: Option<ConfigClient>,
336}
337
338// ---------------------------------------------------------------------------
339// Env resolution / bootstrap validation
340// ---------------------------------------------------------------------------
341
342/// Blank-aware presence: a set-but-whitespace value counts as missing.
343fn non_blank(v: Option<String>) -> Option<String> {
344    v.and_then(|s| if s.trim().is_empty() { None } else { Some(s) })
345}
346
347fn env_var(name: &str) -> Option<String> {
348    non_blank(env::var(name).ok())
349}
350
351struct ResolvedContainerEnv {
352    api_url: String,
353    auth_url: String,
354    client_id: String,
355    client_secret: String,
356    org_id: String,
357    environment: String,
358}
359
360/// Resolve and validate the container-mode env contract (§1). Returns the
361/// resolved values, or [`ConfigBootstrapError`] listing exactly which required
362/// vars are missing/blank. No partial result.
363fn resolve_and_validate_env(
364    options: &InitContainerConfigOptions,
365) -> Result<ResolvedContainerEnv, ConfigBootstrapError> {
366    let api_url = non_blank(options.api_url.clone()).or_else(|| env_var("SMOOAI_CONFIG_API_URL"));
367    let auth_url = non_blank(options.auth_url.clone())
368        .or_else(|| env_var("SMOOAI_CONFIG_AUTH_URL"))
369        .or_else(|| env_var("SMOOAI_AUTH_URL"))
370        .unwrap_or_else(|| "https://auth.smoo.ai".to_string());
371    let client_id = non_blank(options.client_id.clone()).or_else(|| env_var("SMOOAI_CONFIG_CLIENT_ID"));
372    let client_secret = non_blank(options.client_secret.clone())
373        .or_else(|| env_var("SMOOAI_CONFIG_CLIENT_SECRET"))
374        .or_else(|| env_var("SMOOAI_CONFIG_API_KEY"));
375    let org_id = non_blank(options.org_id.clone()).or_else(|| env_var("SMOOAI_CONFIG_ORG_ID"));
376    let environment = non_blank(options.environment.clone()).or_else(|| env_var("SMOOAI_CONFIG_ENV"));
377
378    // When a ConfigClient is injected it already carries api_url/auth/client_id/
379    // secret/org_id — only the environment is still container-required.
380    let client_injected = options.config_client.is_some();
381
382    let mut missing: Vec<String> = Vec::new();
383    if !client_injected {
384        if api_url.is_none() {
385            missing.push("SMOOAI_CONFIG_API_URL".to_string());
386        }
387        if client_id.is_none() {
388            missing.push("SMOOAI_CONFIG_CLIENT_ID".to_string());
389        }
390        if client_secret.is_none() {
391            missing.push("SMOOAI_CONFIG_CLIENT_SECRET".to_string());
392        }
393        if org_id.is_none() {
394            missing.push("SMOOAI_CONFIG_ORG_ID".to_string());
395        }
396    }
397    if environment.is_none() {
398        missing.push("SMOOAI_CONFIG_ENV".to_string());
399    }
400
401    if !missing.is_empty() {
402        return Err(ConfigBootstrapError { missing });
403    }
404
405    Ok(ResolvedContainerEnv {
406        api_url: api_url.unwrap_or_default(),
407        auth_url,
408        client_id: client_id.unwrap_or_default(),
409        client_secret: client_secret.unwrap_or_default(),
410        org_id: org_id.unwrap_or_default(),
411        environment: environment.expect("environment validated present"),
412    })
413}
414
415// ---------------------------------------------------------------------------
416// Health state (§5)
417// ---------------------------------------------------------------------------
418
419struct HealthState {
420    last_fetch_ok: bool,
421    last_fetch_at: Option<Instant>,
422    last_error: Option<String>,
423}
424
425/// A TTL-bounded entry in the sync cache mirror.
426struct SyncCacheEntry {
427    value: Value,
428    expires_at: Option<Instant>,
429}
430
431// ---------------------------------------------------------------------------
432// Shared inner state
433// ---------------------------------------------------------------------------
434
435struct Inner {
436    /// The HTTP config client. `get_value`/`get_all_values` take `&mut self`
437    /// (they mutate the cache), so the client is behind an async mutex.
438    client: Mutex<ConfigClient>,
439    /// Synchronous cache mirror for `get_sync` (avoids blocking on the async
440    /// mutex from a sync context). Seeded by the initial fetch and updated on
441    /// each async resolve. Entries carry the same TTL expiry as the underlying
442    /// client cache so a sync read can't serve a value past hard-expiry.
443    sync_cache: RwLock<std::collections::HashMap<String, SyncCacheEntry>>,
444    environment: String,
445    cache_ttl: Duration,
446    optional_keys: HashSet<String>,
447    health: std::sync::Mutex<HealthState>,
448}
449
450impl Inner {
451    fn is_optional(&self, key: &str) -> bool {
452        self.optional_keys.contains(key)
453    }
454
455    fn record_ok(&self) {
456        let mut h = self.health.lock().expect("health mutex");
457        h.last_fetch_ok = true;
458        h.last_fetch_at = Some(Instant::now());
459        h.last_error = None;
460    }
461
462    fn record_err(&self, msg: String) {
463        let mut h = self.health.lock().expect("health mutex");
464        h.last_error = Some(msg);
465    }
466
467    fn health(&self) -> ConfigHealth {
468        let h = self.health.lock().expect("health mutex");
469        if !h.last_fetch_ok {
470            return ConfigHealth::Unhealthy {
471                reason: h
472                    .last_error
473                    .clone()
474                    .unwrap_or_else(|| "initial config fetch has not succeeded".to_string()),
475            };
476        }
477        if let (Some(err), Some(at)) = (h.last_error.as_ref(), h.last_fetch_at) {
478            // Serve healthy while within the cache TTL of the last good fetch
479            // even if a background refresh just failed. Past the hard TTL, a
480            // failed refresh flips us unhealthy (§5).
481            if at.elapsed() > self.cache_ttl {
482                return ConfigHealth::Unhealthy {
483                    reason: format!(
484                        "last config refresh failed and cache TTL ({:?}) expired: {err}",
485                        self.cache_ttl
486                    ),
487                };
488            }
489        }
490        ConfigHealth::Healthy
491    }
492
493    fn sync_cached(&self, key: &str) -> Option<Value> {
494        let guard = self.sync_cache.read().expect("sync cache read");
495        let entry = guard.get(key)?;
496        if let Some(expires_at) = entry.expires_at {
497            if Instant::now() > expires_at {
498                return None;
499            }
500        }
501        Some(entry.value.clone())
502    }
503
504    fn seed_sync(&self, key: &str, value: Value) {
505        let expires_at = Some(Instant::now() + self.cache_ttl);
506        self.sync_cache
507            .write()
508            .expect("sync cache write")
509            .insert(key.to_string(), SyncCacheEntry { value, expires_at });
510    }
511
512    /// Async resolve for a single key. Order matches the existing chain's
513    /// env-over-http precedence: an explicitly-set process env var wins, else
514    /// the HTTP (config server) value. Blob/file tiers are disabled (§2).
515    async fn resolve(&self, key: &str) -> (Option<Value>, Vec<ConfigTier>) {
516        let mut tried = vec![ConfigTier::Env];
517
518        // env tier — explicit process override.
519        if let Some(from_env) = env_var(&camel_to_upper_snake(key)) {
520            let value = Value::String(from_env);
521            {
522                let mut client = self.client.lock().await;
523                client.seed_cache(key, value.clone(), Some(&self.environment));
524            }
525            self.seed_sync(key, value.clone());
526            return (Some(value), tried);
527        }
528
529        // http tier — the blessed container path.
530        tried.push(ConfigTier::Http);
531        let result = {
532            let mut client = self.client.lock().await;
533            client.get_value(key, Some(&self.environment)).await
534        };
535        match result {
536            Ok(value) => {
537                self.record_ok();
538                if is_present(&value) {
539                    self.seed_sync(key, value.clone());
540                    (Some(value), tried)
541                } else {
542                    (None, tried)
543                }
544            }
545            Err(err) => {
546                self.record_err(err.to_string());
547                // §5: serve last-good from cache until TTL hard-expiry.
548                let cached = {
549                    let client = self.client.lock().await;
550                    client.get_cached_value(key, Some(&self.environment))
551                };
552                match cached.filter(is_present) {
553                    Some(value) => {
554                        self.seed_sync(key, value.clone());
555                        (Some(value), tried)
556                    }
557                    None => (None, tried),
558                }
559            }
560        }
561    }
562
563    /// Sync resolve for `get_sync`. Reads the env tier then the sync cache
564    /// mirror (which was seeded by the initial fetch + later async resolves).
565    fn sync_resolve(&self, key: &str) -> (Option<Value>, Vec<ConfigTier>) {
566        let mut tried = vec![ConfigTier::Env];
567        if let Some(from_env) = env_var(&camel_to_upper_snake(key)) {
568            return (Some(Value::String(from_env)), tried);
569        }
570        tried.push(ConfigTier::Http);
571        (self.sync_cached(key).filter(is_present), tried)
572    }
573
574    async fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
575        let (value, tried) = self.resolve(key).await;
576        match value {
577            Some(v) => Ok(Some(v)),
578            None => {
579                if self.is_optional(key) {
580                    Ok(None)
581                } else {
582                    Err(ConfigError::key_unresolved(key, &self.environment, tried))
583                }
584            }
585        }
586    }
587
588    fn get_sync(&self, key: &str) -> Result<Option<Value>, ConfigError> {
589        let (value, tried) = self.sync_resolve(key);
590        match value {
591            Some(v) => Ok(Some(v)),
592            None => {
593                if self.is_optional(key) {
594                    Ok(None)
595                } else {
596                    Err(ConfigError::key_unresolved(key, &self.environment, tried))
597                }
598            }
599        }
600    }
601}
602
603/// A JSON value is "present" unless it's `null`. Empty strings count as present
604/// here (the server stores an explicit empty string as a real value); absence
605/// is modeled as `null` / missing in the response.
606fn is_present(v: &Value) -> bool {
607    !v.is_null()
608}
609
610// ---------------------------------------------------------------------------
611// Handle + tier accessors
612// ---------------------------------------------------------------------------
613
614/// The handle returned by [`init_container_config`]. Exposes the three tier
615/// accessors ([`Self::secret_config`], [`Self::public_config`],
616/// [`Self::feature_flag`]) with §3 fail-loud `get`/`get_sync`, a non-throwing
617/// [`Self::health`] for k8s readiness/liveness probes, and the underlying
618/// [`Self::client`] (escape hatch).
619///
620/// Cheap to [`Clone`] — clones share the same underlying client + cache.
621#[derive(Clone)]
622pub struct ContainerConfigHandle {
623    inner: Arc<Inner>,
624}
625
626impl fmt::Debug for ContainerConfigHandle {
627    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628        f.debug_struct("ContainerConfigHandle")
629            .field("environment", &self.inner.environment)
630            .field("cache_ttl", &self.inner.cache_ttl)
631            .field("health", &self.inner.health())
632            .finish_non_exhaustive()
633    }
634}
635
636impl ContainerConfigHandle {
637    /// Secret-tier accessor (fail-loud `get` / `get_sync`).
638    pub fn secret_config(&self) -> SecretConfigAccessor<'_> {
639        SecretConfigAccessor { inner: &self.inner }
640    }
641
642    /// Public-tier accessor (fail-loud `get` / `get_sync`).
643    pub fn public_config(&self) -> PublicConfigAccessor<'_> {
644        PublicConfigAccessor { inner: &self.inner }
645    }
646
647    /// Feature-flag-tier accessor (fail-loud `get` / `get_sync`).
648    pub fn feature_flag(&self) -> FeatureFlagAccessor<'_> {
649        FeatureFlagAccessor { inner: &self.inner }
650    }
651
652    /// Cheap, non-failing status for readiness/liveness probes (§4).
653    pub fn health(&self) -> ConfigHealth {
654        self.inner.health()
655    }
656
657    /// Run a closure with the underlying [`ConfigClient`] (escape hatch for
658    /// advanced callers). The client is behind an async mutex; this borrows it
659    /// for the duration of the call.
660    pub async fn with_client<R>(&self, f: impl FnOnce(&mut ConfigClient) -> R) -> R {
661        let mut client = self.inner.client.lock().await;
662        f(&mut client)
663    }
664}
665
666/// Generate the three near-identical tier accessor structs. The resolution
667/// chain is identical across tiers in container mode (env → http); the
668/// per-tier types exist for API parity with the TS `secretConfig` /
669/// `publicConfig` / `featureFlag` split and for call-site readability.
670macro_rules! tier_accessor {
671    ($(#[$meta:meta])* $name:ident) => {
672        $(#[$meta])*
673        pub struct $name<'a> {
674            inner: &'a Inner,
675        }
676
677        impl $name<'_> {
678            /// Async fail-loud read. A required key that resolves absent returns
679            /// [`ConfigError::KeyUnresolved`]; an optional key returns `Ok(None)`.
680            pub async fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
681                self.inner.get(key).await
682            }
683
684            /// Sync fail-loud read off the cache mirror. A required key that is
685            /// not cached returns [`ConfigError::KeyUnresolved`] (never a silent
686            /// absent value); an optional key returns `Ok(None)`.
687            pub fn get_sync(&self, key: &str) -> Result<Option<Value>, ConfigError> {
688                self.inner.get_sync(key)
689            }
690        }
691    };
692}
693
694tier_accessor!(
695    /// Secret-tier accessor returned by [`ContainerConfigHandle::secret_config`].
696    SecretConfigAccessor
697);
698tier_accessor!(
699    /// Public-tier accessor returned by [`ContainerConfigHandle::public_config`].
700    PublicConfigAccessor
701);
702tier_accessor!(
703    /// Feature-flag accessor returned by [`ContainerConfigHandle::feature_flag`].
704    FeatureFlagAccessor
705);
706
707// ---------------------------------------------------------------------------
708// init_container_config
709// ---------------------------------------------------------------------------
710
711/// Explicit container-mode bootstrap (§4). Validates the §1 env, constructs the
712/// M2M [`TokenProvider`] + [`ConfigClient`], and performs an **initial token
713/// mint + config fetch** so auth/network failures surface at startup, not on
714/// first read. Returns a [`ContainerConfigHandle`] whose accessors are
715/// fail-loud (§3).
716///
717/// # Errors
718/// - [`ConfigError::Bootstrap`] when container-required env is missing/blank.
719/// - [`ConfigError::Fetch`] on auth/network failure during the initial fetch.
720pub async fn init_container_config(options: InitContainerConfigOptions) -> Result<ContainerConfigHandle, ConfigError> {
721    let env = resolve_and_validate_env(&options)?;
722    let cache_ttl = options.cache_ttl.unwrap_or(DEFAULT_CACHE_TTL);
723    let refresh_buffer = options
724        .token_refresh_buffer_seconds
725        .unwrap_or(DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS);
726    let optional_keys: HashSet<String> = options.optional_keys.iter().cloned().collect();
727
728    // Build the ConfigClient. When the caller injects one (test/embedding seam)
729    // it already carries its own TokenProvider, so we don't build a second one
730    // (env creds may be empty in that path).
731    let mut client = match options.config_client {
732        Some(c) => c,
733        None => {
734            let provider = TokenProvider::with_options(
735                &env.auth_url,
736                &env.client_id,
737                &env.client_secret,
738                Duration::from_secs(refresh_buffer),
739                reqwest::Client::new(),
740            )
741            .map_err(|e| ConfigError::Fetch(e.to_string()))?;
742            ConfigClient::with_token_provider(&env.api_url, Arc::new(provider), &env.org_id, &env.environment)
743        }
744    };
745    client.set_cache_ttl(Some(cache_ttl));
746
747    // Initial config fetch — fail loud at startup, not first read. The OAuth
748    // token mint happens inside get_all_values (the ConfigClient's
749    // TokenProvider exchanges on the first authed request), so an auth failure
750    // surfaces here too. A pod that can't reach the config server should
751    // CrashLoop visibly, not start degraded.
752    let initial = client.get_all_values(Some(&env.environment)).await;
753    let mut sync_cache = std::collections::HashMap::new();
754    let seeded_expires_at = Some(Instant::now() + cache_ttl);
755    let health = match initial {
756        Ok(values) => {
757            for (k, v) in values {
758                if is_present(&v) {
759                    sync_cache.insert(
760                        k,
761                        SyncCacheEntry {
762                            value: v,
763                            expires_at: seeded_expires_at,
764                        },
765                    );
766                }
767            }
768            HealthState {
769                last_fetch_ok: true,
770                last_fetch_at: Some(Instant::now()),
771                last_error: None,
772            }
773        }
774        Err(err) => {
775            return Err(ConfigError::Fetch(err.to_string()));
776        }
777    };
778
779    // `schema` is accepted for parity + the default-required posture (every
780    // schema key is required unless in optional_keys). The Rust SDK does not
781    // pre-enumerate schema keys for reads — required-ness is enforced per-read:
782    // any key not in optional_keys that resolves absent fails loud. Holding the
783    // schema keeps the API symmetric with the other SDKs and reserves room for
784    // schema-driven validation without a breaking signature change.
785    let _ = &options.schema;
786
787    let inner = Arc::new(Inner {
788        client: Mutex::new(client),
789        sync_cache: RwLock::new(sync_cache),
790        environment: env.environment,
791        cache_ttl,
792        optional_keys,
793        health: std::sync::Mutex::new(health),
794    });
795
796    Ok(ContainerConfigHandle { inner })
797}
798
799/// Standalone health check (§4) for a handle. Exposed both as
800/// [`ContainerConfigHandle::health`] and as this free function for call sites
801/// that prefer the functional form. Never fails.
802pub fn config_health(handle: &ContainerConfigHandle) -> ConfigHealth {
803    handle.health()
804}
805
806// ---------------------------------------------------------------------------
807// Mode selection (§2)
808// ---------------------------------------------------------------------------
809
810/// Mode the SDK should run in, per §2. [`Container`](Mode::Container) means
811/// HTTP-primary fail-loud; [`Default`](Mode::Default) means the existing
812/// blob → env → http → file chain.
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814pub enum Mode {
815    /// Container mode (HTTP-primary, fail-loud).
816    Container,
817    /// Existing default behavior (Lambda blob / local file chain).
818    Default,
819}
820
821/// Inputs for [`select_mode`]. When a field is `None`, the corresponding env
822/// var is read.
823#[derive(Default)]
824pub struct SelectModeInputs {
825    /// `SMOOAI_CONFIG_MODE`.
826    pub mode: Option<String>,
827    /// `SMOOAI_CONFIG_CLIENT_ID`.
828    pub client_id: Option<String>,
829    /// `SMOOAI_CONFIG_CLIENT_SECRET` (or legacy `SMOOAI_CONFIG_API_KEY`).
830    pub client_secret: Option<String>,
831    /// `SMOOAI_CONFIG_API_URL`.
832    pub api_url: Option<String>,
833    /// Whether a baked blob source is present (`SMOO_CONFIG_KEY` +
834    /// `SMOO_CONFIG_KEY_FILE`). When `None`, derived from those env vars.
835    pub blob_present: Option<bool>,
836    /// Whether a local `.smooai-config/` file source is present. When `None`,
837    /// treated as `false`.
838    pub file_present: Option<bool>,
839}
840
841// Logged once per process when container mode is auto-selected.
842static AUTO_SELECT_LOGGED: AtomicBool = AtomicBool::new(false);
843
844/// Mode selection (§2). Resolution order:
845///   1. `SMOOAI_CONFIG_MODE=container` → container mode (explicit).
846///   2. else if a blob/file source is present → default (Lambda/local).
847///   3. else if CLIENT_ID + CLIENT_SECRET + API_URL all set → container (auto;
848///      logs once that container mode was auto-selected).
849///   4. else → default.
850///
851/// Container mode MUST NOT silently degrade to the file tier — that decision is
852/// enforced by [`init_container_config`]'s bootstrap validation; this only
853/// decides which mode to enter.
854pub fn select_mode(inputs: Option<SelectModeInputs>) -> Mode {
855    let inputs = inputs.unwrap_or_default();
856
857    let mode = non_blank(inputs.mode).or_else(|| env_var("SMOOAI_CONFIG_MODE"));
858    if mode
859        .as_deref()
860        .map(|m| m.eq_ignore_ascii_case("container"))
861        .unwrap_or(false)
862    {
863        return Mode::Container;
864    }
865
866    let blob_present = inputs
867        .blob_present
868        .unwrap_or_else(|| env_var("SMOO_CONFIG_KEY").is_some() && env_var("SMOO_CONFIG_KEY_FILE").is_some());
869    let file_present = inputs.file_present.unwrap_or(false);
870    if blob_present || file_present {
871        return Mode::Default;
872    }
873
874    let client_id = non_blank(inputs.client_id).or_else(|| env_var("SMOOAI_CONFIG_CLIENT_ID"));
875    let client_secret = non_blank(inputs.client_secret)
876        .or_else(|| env_var("SMOOAI_CONFIG_CLIENT_SECRET"))
877        .or_else(|| env_var("SMOOAI_CONFIG_API_KEY"));
878    let api_url = non_blank(inputs.api_url).or_else(|| env_var("SMOOAI_CONFIG_API_URL"));
879
880    if client_id.is_some() && client_secret.is_some() && api_url.is_some() {
881        if !AUTO_SELECT_LOGGED.swap(true, Ordering::Relaxed) {
882            eprintln!(
883                "[smooai-config] container mode auto-selected \
884                 (CLIENT_ID + CLIENT_SECRET + API_URL set, no blob/file source present)"
885            );
886        }
887        return Mode::Container;
888    }
889    Mode::Default
890}
891
892/// Test-only: reset the once-per-process auto-select log latch.
893#[doc(hidden)]
894pub fn __reset_select_mode_log_for_tests() {
895    AUTO_SELECT_LOGGED.store(false, Ordering::Relaxed);
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    fn non_blank_treats_whitespace_as_absent() {
904        assert_eq!(non_blank(Some("   ".to_string())), None);
905        assert_eq!(non_blank(Some("".to_string())), None);
906        assert_eq!(non_blank(Some("x".to_string())), Some("x".to_string()));
907        assert_eq!(non_blank(None), None);
908    }
909
910    #[test]
911    fn tier_strings_match_wire_contract() {
912        assert_eq!(ConfigTier::Blob.as_str(), "blob");
913        assert_eq!(ConfigTier::Env.as_str(), "env");
914        assert_eq!(ConfigTier::Http.as_str(), "http");
915        assert_eq!(ConfigTier::File.as_str(), "file");
916    }
917
918    #[test]
919    fn bootstrap_error_message_lists_vars() {
920        let e = ConfigBootstrapError {
921            missing: vec!["SMOOAI_CONFIG_API_URL".to_string(), "SMOOAI_CONFIG_ENV".to_string()],
922        };
923        let msg = e.to_string();
924        assert!(msg.contains("SMOOAI_CONFIG_API_URL"));
925        assert!(msg.contains("SMOOAI_CONFIG_ENV"));
926        assert!(msg.contains("these variables"));
927    }
928
929    #[test]
930    fn bootstrap_error_singular_phrasing() {
931        let e = ConfigBootstrapError {
932            missing: vec!["SMOOAI_CONFIG_ENV".to_string()],
933        };
934        assert!(e.to_string().contains("this variable"));
935    }
936
937    #[test]
938    fn key_unresolved_message_carries_context() {
939        let e = ConfigKeyUnresolvedError {
940            key: "stripeApiKey".to_string(),
941            env: "production".to_string(),
942            tried_tiers: vec![ConfigTier::Env, ConfigTier::Http],
943        };
944        let msg = e.to_string();
945        assert!(msg.contains("stripeApiKey"));
946        assert!(msg.contains("production"));
947        assert!(msg.contains("env → http"));
948        assert!(msg.contains("optional"));
949    }
950
951    #[test]
952    fn config_error_wraps_typed_variants_as_source() {
953        let bootstrap: ConfigError = ConfigBootstrapError {
954            missing: vec!["SMOOAI_CONFIG_ENV".to_string()],
955        }
956        .into();
957        assert!(std::error::Error::source(&bootstrap).is_some());
958        assert!(matches!(bootstrap, ConfigError::Bootstrap(_)));
959
960        let unresolved = ConfigError::key_unresolved("k", "production", vec![ConfigTier::Env, ConfigTier::Http]);
961        match &unresolved {
962            ConfigError::KeyUnresolved(e) => {
963                assert_eq!(e.key, "k");
964                assert_eq!(e.tried_tiers, vec![ConfigTier::Env, ConfigTier::Http]);
965            }
966            other => panic!("expected KeyUnresolved, got {other:?}"),
967        }
968    }
969
970    #[test]
971    fn config_health_status_and_helpers() {
972        assert_eq!(ConfigHealth::Healthy.status(), "healthy");
973        assert!(ConfigHealth::Healthy.is_healthy());
974        let u = ConfigHealth::Unhealthy {
975            reason: "x".to_string(),
976        };
977        assert_eq!(u.status(), "unhealthy");
978        assert!(!u.is_healthy());
979    }
980
981    #[test]
982    fn is_present_only_null_is_absent() {
983        assert!(!is_present(&Value::Null));
984        assert!(is_present(&json_str("")));
985        assert!(is_present(&Value::Bool(false)));
986        assert!(is_present(&serde_json::json!(0)));
987    }
988
989    #[test]
990    fn defaults_match_contract() {
991        assert_eq!(DEFAULT_CACHE_TTL, Duration::from_secs(30));
992        assert_eq!(DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS, 60);
993    }
994
995    fn json_str(s: &str) -> Value {
996        Value::String(s.to_string())
997    }
998}