Skip to main content

secrets_core/
resolver.rs

1#[cfg(feature = "env")]
2use crate::backend::env::EnvBackend;
3#[cfg(feature = "file")]
4use crate::backend::file::FileBackend;
5use crate::embedded::{CoreBuilder, MemoryBackend, MemoryKeyProvider, SecretsCore, SecretsError};
6use crate::probe;
7use crate::provider::Provider;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11/// High-level configuration for the default resolver.
12#[derive(Debug, Clone)]
13pub struct ResolverConfig {
14    provider: Provider,
15    tenant: Option<String>,
16    team: Option<String>,
17    cache_ttl: Option<Duration>,
18    cache_capacity: Option<usize>,
19    file_root: Option<PathBuf>,
20    dev_fallback: bool,
21}
22
23impl ResolverConfig {
24    /// Create a configuration with default values (auto provider detection).
25    pub fn new() -> Self {
26        Self {
27            provider: Provider::Auto,
28            tenant: None,
29            team: None,
30            cache_ttl: None,
31            cache_capacity: None,
32            file_root: None,
33            dev_fallback: true,
34        }
35    }
36
37    /// Load configuration from environment variables.
38    ///
39    /// * `GREENTIC_SECRETS_PROVIDER` selects the provider (`auto`, `local`, `aws`, `azure`,
40    ///   `gcp`, `k8s`).
41    /// * `GREENTIC_SECRETS_DEV` controls whether to fall back to the local backend (default: true).
42    /// * `GREENTIC_SECRETS_FILE_ROOT` configures the local filesystem backend root.
43    pub fn from_env() -> Self {
44        let mut config = ResolverConfig::new();
45
46        if let Ok(provider) = std::env::var("GREENTIC_SECRETS_PROVIDER")
47            && let Some(parsed) = Provider::from_env_value(&provider)
48        {
49            config.provider = parsed;
50        }
51
52        let dev_fallback = std::env::var("GREENTIC_SECRETS_DEV")
53            .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
54            .unwrap_or(true);
55        config.dev_fallback = dev_fallback;
56
57        if let Ok(root) = std::env::var("GREENTIC_SECRETS_FILE_ROOT")
58            && !root.trim().is_empty()
59        {
60            config.file_root = Some(PathBuf::from(root));
61        }
62
63        config
64    }
65
66    /// Override the provider selection.
67    pub fn provider(mut self, provider: Provider) -> Self {
68        self.provider = provider;
69        self
70    }
71
72    /// Set the tenant scope for secrets.
73    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
74        self.tenant = Some(tenant.into());
75        self
76    }
77
78    /// Set the team scope.
79    pub fn team(mut self, team: impl Into<String>) -> Self {
80        self.team = Some(team.into());
81        self
82    }
83
84    /// Override the default cache TTL.
85    pub fn cache_ttl(mut self, ttl: Duration) -> Self {
86        self.cache_ttl = Some(ttl);
87        self
88    }
89
90    /// Override the cache capacity.
91    pub fn cache_capacity(mut self, capacity: usize) -> Self {
92        self.cache_capacity = Some(capacity);
93        self
94    }
95
96    /// Configure the local filesystem backend root.
97    pub fn file_root<P: AsRef<Path>>(mut self, root: P) -> Self {
98        self.file_root = Some(root.as_ref().to_path_buf());
99        self
100    }
101
102    /// Control whether local fallbacks are enabled when provider detection fails.
103    pub fn dev_fallback(mut self, enabled: bool) -> Self {
104        self.dev_fallback = enabled;
105        self
106    }
107}
108
109impl Default for ResolverConfig {
110    fn default() -> Self {
111        ResolverConfig::new()
112    }
113}
114
115/// Resolver that selects an appropriate backend and exposes JSON/text helpers.
116pub struct DefaultResolver {
117    provider: Provider,
118    core: SecretsCore,
119}
120
121impl DefaultResolver {
122    /// Build a resolver using environment configuration.
123    pub async fn new() -> Result<Self, SecretsError> {
124        Self::from_config(ResolverConfig::from_env()).await
125    }
126
127    /// Build a resolver from the provided configuration.
128    pub async fn from_config(config: ResolverConfig) -> Result<Self, SecretsError> {
129        let mut builder = SecretsCore::builder();
130        builder.clear_backends();
131
132        if let Some(ref tenant) = config.tenant {
133            builder = builder.tenant(tenant.clone());
134        }
135
136        if let Some(ref team) = config.team {
137            builder = builder.team(team.clone());
138        }
139
140        if let Some(ttl) = config.cache_ttl {
141            builder = builder.default_ttl(ttl);
142        }
143
144        if let Some(capacity) = config.cache_capacity {
145            builder = builder.cache_capacity(capacity);
146        }
147
148        let requested = config.provider;
149        let selected = if let Provider::Auto = requested {
150            detect_provider().await
151        } else {
152            requested
153        };
154
155        let (builder, resolved) = configure_builder_for_provider(builder, &config, selected);
156        let core = builder.build().await?;
157
158        Ok(Self {
159            provider: resolved,
160            core,
161        })
162    }
163
164    /// Returns the provider that was selected at runtime.
165    pub fn provider(&self) -> Provider {
166        self.provider
167    }
168
169    /// Returns an immutable reference to the underlying [`SecretsCore`].
170    pub fn core(&self) -> &SecretsCore {
171        &self.core
172    }
173}
174
175impl std::ops::Deref for DefaultResolver {
176    type Target = SecretsCore;
177
178    fn deref(&self) -> &Self::Target {
179        &self.core
180    }
181}
182
183async fn detect_provider() -> Provider {
184    if probe::is_kubernetes().await {
185        return Provider::K8s;
186    }
187
188    if probe::is_aws().await {
189        return Provider::Aws;
190    }
191
192    if probe::is_gcp().await {
193        return Provider::Gcp;
194    }
195
196    if probe::is_azure().await {
197        return Provider::Azure;
198    }
199
200    Provider::Local
201}
202
203fn configure_builder_for_provider(
204    builder: CoreBuilder,
205    config: &ResolverConfig,
206    requested: Provider,
207) -> (CoreBuilder, Provider) {
208    match requested {
209        Provider::Local => configure_local(builder, config),
210        Provider::Aws => configure_aws(builder, config),
211        Provider::Azure => configure_azure(builder, config),
212        Provider::Gcp => configure_gcp(builder, config),
213        Provider::K8s => configure_k8s(builder, config),
214        Provider::Auto => configure_local(builder, config),
215    }
216}
217
218fn configure_local(mut builder: CoreBuilder, config: &ResolverConfig) -> (CoreBuilder, Provider) {
219    builder = builder.backend_named("memory", MemoryBackend::new(), MemoryKeyProvider::default());
220
221    if config.dev_fallback {
222        #[cfg(feature = "env")]
223        {
224            builder = builder.backend_named("env", EnvBackend::new(), MemoryKeyProvider::default());
225        }
226    }
227
228    #[cfg(feature = "file")]
229    if let Some(root) = config.file_root.as_ref() {
230        builder = builder.backend_named(
231            "file",
232            FileBackend::new(root.clone()),
233            MemoryKeyProvider::default(),
234        );
235    }
236
237    (builder, Provider::Local)
238}
239
240fn configure_aws(builder: CoreBuilder, config: &ResolverConfig) -> (CoreBuilder, Provider) {
241    #[cfg(feature = "aws")]
242    {
243        let _ = config;
244        let builder = builder.backend_named(
245            "aws",
246            crate::backend::aws::AwsSecretsManagerBackend::new(),
247            MemoryKeyProvider::default(),
248        );
249        (builder, Provider::Aws)
250    }
251
252    #[cfg(not(feature = "aws"))]
253    {
254        tracing::warn!(
255            "aws provider requested but the `aws` feature is not enabled; falling back to local provider"
256        );
257        configure_local(builder, config)
258    }
259}
260
261fn configure_azure(builder: CoreBuilder, config: &ResolverConfig) -> (CoreBuilder, Provider) {
262    #[cfg(feature = "azure")]
263    {
264        let _ = config;
265        let builder = builder.backend_named(
266            "azure",
267            crate::backend::azure::AzureKeyVaultBackend::new(),
268            MemoryKeyProvider::default(),
269        );
270        (builder, Provider::Azure)
271    }
272
273    #[cfg(not(feature = "azure"))]
274    {
275        tracing::warn!(
276            "azure provider requested but the `azure` feature is not enabled; falling back to local provider"
277        );
278        configure_local(builder, config)
279    }
280}
281
282fn configure_gcp(builder: CoreBuilder, config: &ResolverConfig) -> (CoreBuilder, Provider) {
283    #[cfg(feature = "gcp")]
284    {
285        let _ = config;
286        let builder = builder.backend_named(
287            "gcp",
288            crate::backend::gcp::GcpSecretsManagerBackend::new(),
289            MemoryKeyProvider::default(),
290        );
291        (builder, Provider::Gcp)
292    }
293
294    #[cfg(not(feature = "gcp"))]
295    {
296        tracing::warn!(
297            "gcp provider requested but the `gcp` feature is not enabled; falling back to local provider"
298        );
299        configure_local(builder, config)
300    }
301}
302
303fn configure_k8s(builder: CoreBuilder, config: &ResolverConfig) -> (CoreBuilder, Provider) {
304    #[cfg(feature = "k8s")]
305    {
306        let _ = config;
307        let builder = builder.backend_named(
308            "k8s",
309            crate::backend::k8s::K8sBackend::new(),
310            MemoryKeyProvider::default(),
311        );
312        (builder, Provider::K8s)
313    }
314
315    #[cfg(not(feature = "k8s"))]
316    {
317        tracing::warn!(
318            "k8s provider requested but the `k8s` feature is not enabled; falling back to local provider"
319        );
320        configure_local(builder, config)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde_json::{Value, json};
328
329    #[tokio::test]
330    async fn defaults_to_local_provider() {
331        let resolver = DefaultResolver::from_config(
332            ResolverConfig::new()
333                .provider(Provider::Local)
334                .tenant("example")
335                .team("core"),
336        )
337        .await
338        .expect("resolver");
339
340        assert_eq!(resolver.provider(), Provider::Local);
341
342        resolver
343            .put_json(
344                "secrets://dev/example/core/configs/api",
345                &json!({ "token": "abc" }),
346            )
347            .await
348            .expect("put");
349
350        let value: Value = resolver
351            .get_json("secrets://dev/example/core/configs/api")
352            .await
353            .expect("get");
354        assert_eq!(value["token"], "abc");
355    }
356
357    #[tokio::test]
358    async fn falls_back_when_feature_disabled() {
359        let resolver = DefaultResolver::from_config(
360            ResolverConfig::new()
361                .provider(Provider::Aws)
362                .tenant("test")
363                .team("core"),
364        )
365        .await
366        .expect("resolver");
367
368        // When aws feature is disabled we should fall back to local to avoid panics.
369        assert!(
370            matches!(resolver.provider(), Provider::Aws | Provider::Local),
371            "resolver should either use AWS (when feature enabled) or fallback to Local"
372        );
373    }
374}