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}