spin_runtime_config/
lib.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context as _;
4use spin_common::ui::quoted_path;
5use spin_factor_key_value::runtime_config::spin::{self as key_value};
6use spin_factor_key_value::KeyValueFactor;
7use spin_factor_llm::{spin as llm, LlmFactor};
8use spin_factor_outbound_http::OutboundHttpFactor;
9use spin_factor_outbound_mqtt::OutboundMqttFactor;
10use spin_factor_outbound_mysql::OutboundMysqlFactor;
11use spin_factor_outbound_networking::runtime_config::spin::SpinTlsRuntimeConfig;
12use spin_factor_outbound_networking::OutboundNetworkingFactor;
13use spin_factor_outbound_pg::OutboundPgFactor;
14use spin_factor_outbound_redis::OutboundRedisFactor;
15use spin_factor_sqlite::SqliteFactor;
16use spin_factor_variables::VariablesFactor;
17use spin_factor_wasi::WasiFactor;
18use spin_factors::runtime_config::toml::GetTomlValue as _;
19use spin_factors::{
20    runtime_config::toml::TomlKeyTracker, FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer,
21};
22use spin_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore};
23use spin_sqlite as sqlite;
24use spin_trigger::cli::UserProvidedPath;
25use toml::Value;
26
27/// The default state directory for the trigger.
28pub const DEFAULT_STATE_DIR: &str = ".spin";
29
30/// A runtime configuration which has been resolved from a runtime config source.
31///
32/// Includes other pieces of configuration that are used to resolve the runtime configuration.
33pub struct ResolvedRuntimeConfig<T> {
34    /// The resolved runtime configuration.
35    pub runtime_config: T,
36    /// The resolver used to resolve key-value stores from runtime configuration.
37    pub key_value_resolver: key_value::RuntimeConfigResolver,
38    /// The resolver used to resolve sqlite databases from runtime configuration.
39    pub sqlite_resolver: sqlite::RuntimeConfigResolver,
40    /// The fully resolved state directory.
41    ///
42    /// `None` is used for an "unset" state directory which each factor will treat differently.
43    pub state_dir: Option<PathBuf>,
44    /// The fully resolved log directory.
45    ///
46    /// `None` is used for an "unset" log directory.
47    pub log_dir: Option<PathBuf>,
48    /// The maximum memory allocation limit.
49    pub max_instance_memory: Option<usize>,
50    /// The input TOML, for informational summaries.
51    pub toml: toml::Table,
52}
53
54impl<T> ResolvedRuntimeConfig<T> {
55    pub fn summarize(&self, runtime_config_path: Option<&Path>) {
56        let summarize_labeled_typed_tables = |key| {
57            let mut summaries = vec![];
58            if let Some(tables) = self.toml.get(key).and_then(Value::as_table) {
59                for (label, config) in tables {
60                    if let Some(ty) = config.get("type").and_then(Value::as_str) {
61                        summaries.push(format!("[{key}.{label}: {ty}]"))
62                    }
63                }
64            }
65            summaries
66        };
67
68        let mut summaries = vec![];
69        // [key_value_store.<label>: <type>]
70        summaries.extend(summarize_labeled_typed_tables("key_value_store"));
71        // [sqlite_database.<label>: <type>]
72        summaries.extend(summarize_labeled_typed_tables("sqlite_database"));
73        // [llm_compute: <type>]
74        if let Some(table) = self.toml.get("llm_compute").and_then(Value::as_table) {
75            if let Some(ty) = table.get("type").and_then(Value::as_str) {
76                summaries.push(format!("[llm_compute: {ty}"));
77            }
78        }
79        if !summaries.is_empty() {
80            let summaries = summaries.join(", ");
81            let from_path = runtime_config_path
82                .map(|path| format!("from {}", quoted_path(path)))
83                .unwrap_or_default();
84            println!("Using runtime config {summaries} {from_path}");
85        }
86    }
87}
88
89impl<T> ResolvedRuntimeConfig<T>
90where
91    T: for<'a, 'b> TryFrom<TomlRuntimeConfigSource<'a, 'b>>,
92    for<'a, 'b> <T as TryFrom<TomlRuntimeConfigSource<'a, 'b>>>::Error: Into<anyhow::Error>,
93{
94    /// Creates a new resolved runtime configuration from a runtime config source TOML file.
95    ///
96    /// `provided_state_dir` is the explicitly provided state directory, if any.
97    pub fn from_file(
98        runtime_config_path: Option<&Path>,
99        local_app_dir: Option<PathBuf>,
100        provided_state_dir: UserProvidedPath,
101        provided_log_dir: UserProvidedPath,
102    ) -> anyhow::Result<Self> {
103        let toml = match runtime_config_path {
104            Some(runtime_config_path) => {
105                let file = std::fs::read_to_string(runtime_config_path).with_context(|| {
106                    format!(
107                        "failed to read runtime config file '{}'",
108                        runtime_config_path.display()
109                    )
110                })?;
111                toml::from_str(&file).with_context(|| {
112                    format!(
113                        "failed to parse runtime config file '{}' as toml",
114                        runtime_config_path.display()
115                    )
116                })?
117            }
118            None => Default::default(),
119        };
120        let toml_resolver =
121            TomlResolver::new(&toml, local_app_dir, provided_state_dir, provided_log_dir);
122
123        Self::new(toml_resolver, runtime_config_path)
124    }
125
126    /// Creates a new resolved runtime configuration from a TOML table.
127    pub fn new(
128        toml_resolver: TomlResolver<'_>,
129        runtime_config_path: Option<&Path>,
130    ) -> anyhow::Result<Self> {
131        let runtime_config_dir = runtime_config_path
132            .and_then(Path::parent)
133            .map(ToOwned::to_owned);
134        let state_dir = toml_resolver.state_dir()?;
135        let tls_resolver = runtime_config_dir.clone().map(SpinTlsRuntimeConfig::new);
136        let key_value_resolver = key_value_config_resolver(runtime_config_dir, state_dir.clone());
137        let sqlite_resolver = sqlite_config_resolver(state_dir.clone())
138            .context("failed to resolve sqlite runtime config")?;
139
140        let toml = toml_resolver.toml();
141        let log_dir = toml_resolver.log_dir()?;
142        let max_instance_memory = toml_resolver.max_instance_memory()?;
143
144        let source = TomlRuntimeConfigSource::new(
145            toml_resolver,
146            &key_value_resolver,
147            tls_resolver.as_ref(),
148            &sqlite_resolver,
149        );
150
151        // Note: all valid fields in the runtime config must have been referenced at
152        // this point or the finalizer will fail due to `validate_all_keys_used`
153        // not passing.
154        let runtime_config: T = source.try_into().map_err(Into::into)?;
155
156        Ok(Self {
157            runtime_config,
158            key_value_resolver,
159            sqlite_resolver,
160            state_dir,
161            log_dir,
162            max_instance_memory,
163            toml,
164        })
165    }
166
167    /// The fully resolved state directory.
168    pub fn state_dir(&self) -> Option<PathBuf> {
169        self.state_dir.clone()
170    }
171
172    /// The fully resolved state directory.
173    pub fn log_dir(&self) -> Option<PathBuf> {
174        self.log_dir.clone()
175    }
176
177    /// The maximum memory allocation limit.
178    pub fn max_instance_memory(&self) -> Option<usize> {
179        self.max_instance_memory
180    }
181}
182
183#[derive(Clone, Debug)]
184/// Resolves runtime configuration from a TOML file.
185pub struct TomlResolver<'a> {
186    table: TomlKeyTracker<'a>,
187    /// The local app directory.
188    local_app_dir: Option<PathBuf>,
189    /// Explicitly provided state directory.
190    state_dir: UserProvidedPath,
191    /// Explicitly provided log directory.
192    log_dir: UserProvidedPath,
193}
194
195impl<'a> TomlResolver<'a> {
196    /// Create a new TOML resolver.
197    pub fn new(
198        table: &'a toml::Table,
199        local_app_dir: Option<PathBuf>,
200        state_dir: UserProvidedPath,
201        log_dir: UserProvidedPath,
202    ) -> Self {
203        Self {
204            table: TomlKeyTracker::new(table),
205            local_app_dir,
206            state_dir,
207            log_dir,
208        }
209    }
210
211    /// Get the configured state_directory.
212    ///
213    /// Errors if the path cannot be converted to an absolute path.
214    pub fn state_dir(&self) -> std::io::Result<Option<PathBuf>> {
215        let mut state_dir = self.state_dir.clone();
216        // If the state_dir is not explicitly provided, check the toml.
217        if matches!(state_dir, UserProvidedPath::Default) {
218            let from_toml =
219                self.table
220                    .get("state_dir")
221                    .and_then(|v| v.as_str())
222                    .map(|toml_value| {
223                        if toml_value.is_empty() {
224                            // If the toml value is empty, treat it as unset.
225                            UserProvidedPath::Unset
226                        } else {
227                            // Otherwise, treat the toml value as a provided path.
228                            UserProvidedPath::Provided(PathBuf::from(toml_value))
229                        }
230                    });
231            // If toml value is not provided, use the original value after all.
232            state_dir = from_toml.unwrap_or(state_dir);
233        }
234
235        match (state_dir, &self.local_app_dir) {
236            (UserProvidedPath::Provided(p), _) => Ok(Some(std::path::absolute(p)?)),
237            (UserProvidedPath::Default, Some(local_app_dir)) => {
238                Ok(Some(local_app_dir.join(".spin")))
239            }
240            (UserProvidedPath::Default | UserProvidedPath::Unset, _) => Ok(None),
241        }
242    }
243
244    /// Get the configured log directory.
245    ///
246    /// Errors if the path cannot be converted to an absolute path.
247    pub fn log_dir(&self) -> std::io::Result<Option<PathBuf>> {
248        let mut log_dir = self.log_dir.clone();
249        // If the log_dir is not explicitly provided, check the toml.
250        if matches!(log_dir, UserProvidedPath::Default) {
251            let from_toml = self
252                .table
253                .get("log_dir")
254                .and_then(|v| v.as_str())
255                .map(|toml_value| {
256                    if toml_value.is_empty() {
257                        // If the toml value is empty, treat it as unset.
258                        UserProvidedPath::Unset
259                    } else {
260                        // Otherwise, treat the toml value as a provided path.
261                        UserProvidedPath::Provided(PathBuf::from(toml_value))
262                    }
263                });
264            // If toml value is not provided, use the original value after all.
265            log_dir = from_toml.unwrap_or(log_dir);
266        }
267
268        match log_dir {
269            UserProvidedPath::Provided(p) => Ok(Some(std::path::absolute(p)?)),
270            UserProvidedPath::Default => Ok(self.state_dir()?.map(|p| p.join("logs"))),
271            UserProvidedPath::Unset => Ok(None),
272        }
273    }
274
275    /// Get the configured maximum memory allocation limit.
276    pub fn max_instance_memory(&self) -> anyhow::Result<Option<usize>> {
277        self.table
278            .get("max_instance_memory")
279            .and_then(|v| v.as_integer())
280            .map(|toml_value| toml_value.try_into())
281            .transpose()
282            .map_err(Into::into)
283    }
284
285    /// Validate that all keys in the TOML file have been used.
286    pub fn validate_all_keys_used(&self) -> spin_factors::Result<()> {
287        self.table.validate_all_keys_used()
288    }
289
290    fn toml(&self) -> toml::Table {
291        self.table.as_ref().clone()
292    }
293}
294
295/// The TOML based runtime configuration source Spin CLI.
296pub struct TomlRuntimeConfigSource<'a, 'b> {
297    toml: TomlResolver<'b>,
298    key_value: &'a key_value::RuntimeConfigResolver,
299    tls: Option<&'a SpinTlsRuntimeConfig>,
300    sqlite: &'a sqlite::RuntimeConfigResolver,
301}
302
303impl<'a, 'b> TomlRuntimeConfigSource<'a, 'b> {
304    pub fn new(
305        toml_resolver: TomlResolver<'b>,
306        key_value: &'a key_value::RuntimeConfigResolver,
307        tls: Option<&'a SpinTlsRuntimeConfig>,
308        sqlite: &'a sqlite::RuntimeConfigResolver,
309    ) -> Self {
310        Self {
311            toml: toml_resolver,
312            key_value,
313            tls,
314            sqlite,
315        }
316    }
317}
318
319impl FactorRuntimeConfigSource<KeyValueFactor> for TomlRuntimeConfigSource<'_, '_> {
320    fn get_runtime_config(
321        &mut self,
322    ) -> anyhow::Result<Option<spin_factor_key_value::RuntimeConfig>> {
323        Ok(Some(self.key_value.resolve(Some(&self.toml.table))?))
324    }
325}
326
327impl FactorRuntimeConfigSource<OutboundNetworkingFactor> for TomlRuntimeConfigSource<'_, '_> {
328    fn get_runtime_config(
329        &mut self,
330    ) -> anyhow::Result<Option<<OutboundNetworkingFactor as spin_factors::Factor>::RuntimeConfig>>
331    {
332        let Some(tls) = self.tls else {
333            return Ok(None);
334        };
335        tls.config_from_table(&self.toml.table)
336    }
337}
338
339impl FactorRuntimeConfigSource<VariablesFactor> for TomlRuntimeConfigSource<'_, '_> {
340    fn get_runtime_config(
341        &mut self,
342    ) -> anyhow::Result<Option<<VariablesFactor as spin_factors::Factor>::RuntimeConfig>> {
343        Ok(Some(spin_variables::runtime_config_from_toml(
344            &self.toml.table,
345        )?))
346    }
347}
348
349impl FactorRuntimeConfigSource<OutboundPgFactor> for TomlRuntimeConfigSource<'_, '_> {
350    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
351        Ok(None)
352    }
353}
354
355impl FactorRuntimeConfigSource<OutboundMysqlFactor> for TomlRuntimeConfigSource<'_, '_> {
356    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
357        Ok(None)
358    }
359}
360
361impl FactorRuntimeConfigSource<LlmFactor> for TomlRuntimeConfigSource<'_, '_> {
362    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_llm::RuntimeConfig>> {
363        llm::runtime_config_from_toml(&self.toml.table, self.toml.state_dir()?)
364    }
365}
366
367impl FactorRuntimeConfigSource<OutboundRedisFactor> for TomlRuntimeConfigSource<'_, '_> {
368    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
369        Ok(None)
370    }
371}
372
373impl FactorRuntimeConfigSource<WasiFactor> for TomlRuntimeConfigSource<'_, '_> {
374    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
375        Ok(None)
376    }
377}
378
379impl FactorRuntimeConfigSource<OutboundHttpFactor> for TomlRuntimeConfigSource<'_, '_> {
380    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
381        Ok(None)
382    }
383}
384
385impl FactorRuntimeConfigSource<OutboundMqttFactor> for TomlRuntimeConfigSource<'_, '_> {
386    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
387        Ok(None)
388    }
389}
390
391impl FactorRuntimeConfigSource<SqliteFactor> for TomlRuntimeConfigSource<'_, '_> {
392    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_sqlite::RuntimeConfig>> {
393        Ok(Some(self.sqlite.resolve(&self.toml.table)?))
394    }
395}
396
397impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> {
398    fn finalize(&mut self) -> anyhow::Result<()> {
399        Ok(self.toml.validate_all_keys_used()?)
400    }
401}
402
403const DEFAULT_KEY_VALUE_STORE_LABEL: &str = "default";
404
405/// The key-value runtime configuration resolver.
406///
407/// Takes a base path that all local key-value stores which are configured with
408/// relative paths will be relative to. It also takes a default store base path
409/// which will be used as the directory for the default store.
410pub fn key_value_config_resolver(
411    local_store_base_path: Option<PathBuf>,
412    default_store_base_path: Option<PathBuf>,
413) -> key_value::RuntimeConfigResolver {
414    let mut key_value = key_value::RuntimeConfigResolver::new();
415
416    // Register the supported store types.
417    // Unwraps are safe because the store types are known to not overlap.
418    key_value
419        .register_store_type(spin_key_value_spin::SpinKeyValueStore::new(
420            local_store_base_path.clone(),
421        ))
422        .unwrap();
423    key_value
424        .register_store_type(spin_key_value_redis::RedisKeyValueStore::new())
425        .unwrap();
426    key_value
427        .register_store_type(spin_key_value_azure::AzureKeyValueStore::new(None))
428        .unwrap();
429    key_value
430        .register_store_type(spin_key_value_aws::AwsDynamoKeyValueStore::new())
431        .unwrap();
432
433    // Add handling of "default" store.
434    let default_store_path = default_store_base_path.map(|p| p.join(DEFAULT_SPIN_STORE_FILENAME));
435    // Unwraps are safe because the store is known to be serializable as toml.
436    key_value
437        .add_default_store::<SpinKeyValueStore>(
438            DEFAULT_KEY_VALUE_STORE_LABEL,
439            SpinKeyValueRuntimeConfig::new(default_store_path),
440        )
441        .unwrap();
442
443    key_value
444}
445
446/// The default filename for the SQLite database.
447const DEFAULT_SPIN_STORE_FILENAME: &str = "sqlite_key_value.db";
448
449/// The sqlite runtime configuration resolver.
450///
451/// Takes a path to the directory where the default database should be stored.
452/// If the path is `None`, the default database will be in-memory.
453fn sqlite_config_resolver(
454    default_database_dir: Option<PathBuf>,
455) -> anyhow::Result<sqlite::RuntimeConfigResolver> {
456    let local_database_dir =
457        std::env::current_dir().context("failed to get current working directory")?;
458    Ok(sqlite::RuntimeConfigResolver::new(
459        default_database_dir,
460        local_database_dir,
461    ))
462}
463
464#[cfg(test)]
465mod tests {
466    use std::{collections::HashMap, sync::Arc};
467
468    use spin_factors::RuntimeFactors;
469    use spin_factors_test::TestEnvironment;
470
471    use super::*;
472
473    /// Define a test factor with the given field and factor type.
474    macro_rules! define_test_factor {
475        ($field:ident : $factor:ty) => {
476            #[derive(RuntimeFactors)]
477            struct TestFactors {
478                $field: $factor,
479            }
480            impl TryFrom<TomlRuntimeConfigSource<'_, '_>> for TestFactorsRuntimeConfig {
481                type Error = anyhow::Error;
482
483                fn try_from(value: TomlRuntimeConfigSource<'_, '_>) -> Result<Self, Self::Error> {
484                    Self::from_source(value)
485                }
486            }
487            fn resolve_toml(
488                toml: toml::Table,
489                path: impl AsRef<std::path::Path>,
490            ) -> anyhow::Result<ResolvedRuntimeConfig<TestFactorsRuntimeConfig>> {
491                ResolvedRuntimeConfig::<TestFactorsRuntimeConfig>::new(
492                    toml_resolver(&toml),
493                    Some(path.as_ref()),
494                )
495            }
496        };
497    }
498
499    #[test]
500    fn sqlite_is_configured_correctly() {
501        define_test_factor!(sqlite: SqliteFactor);
502
503        impl TestFactorsRuntimeConfig {
504            /// Get the connection creators for the configured sqlite databases.
505            fn connection_creators(
506                &self,
507            ) -> &HashMap<String, Arc<dyn spin_factor_sqlite::ConnectionCreator>> {
508                &self.sqlite.as_ref().unwrap().connection_creators
509            }
510
511            /// Get the labels of the configured sqlite databases.
512            fn configured_labels(&self) -> Vec<&str> {
513                let mut configured_labels = self
514                    .connection_creators()
515                    .keys()
516                    .map(|s| s.as_str())
517                    .collect::<Vec<_>>();
518                // Sort the labels to ensure consistent ordering.
519                configured_labels.sort();
520                configured_labels
521            }
522        }
523
524        // Test that the default label is added if not provided.
525        let toml = toml::toml! {
526            [sqlite_database.foo]
527            type = "spin"
528        };
529        assert_eq!(
530            resolve_toml(toml, ".")
531                .unwrap()
532                .runtime_config
533                .configured_labels(),
534            vec!["default", "foo"]
535        );
536
537        // Test that the default label is added with an empty toml config.
538        let toml = toml::Table::new();
539        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
540        assert_eq!(runtime_config.configured_labels(), vec!["default"]);
541    }
542
543    #[test]
544    fn key_value_is_configured_correctly() {
545        define_test_factor!(key_value: KeyValueFactor);
546        impl TestFactorsRuntimeConfig {
547            /// Get whether the store manager exists for the given label.
548            fn has_store_manager(&self, label: &str) -> bool {
549                self.key_value.as_ref().unwrap().has_store_manager(label)
550            }
551        }
552
553        // Test that the default label is added if not provided.
554        let toml = toml::toml! {
555            [key_value_store.foo]
556            type = "spin"
557        };
558        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
559        assert!(["default", "foo"]
560            .iter()
561            .all(|label| runtime_config.has_store_manager(label)));
562    }
563
564    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
565    async fn custom_spin_key_value_works_with_custom_paths() -> anyhow::Result<()> {
566        use spin_world::v2::key_value::HostStore;
567        define_test_factor!(key_value: KeyValueFactor);
568        let tmp_dir = tempfile::TempDir::with_prefix("example")?;
569        let absolute_path = tmp_dir.path().join("foo/custom.db");
570        let relative_path = tmp_dir.path().join("custom.db");
571        // Check that the dbs do not exist yet - they will exist by the end of the test
572        assert!(!absolute_path.exists());
573        assert!(!relative_path.exists());
574
575        let path_str = absolute_path.to_str().unwrap();
576        let runtime_config = toml::toml! {
577            [key_value_store.absolute]
578            type = "spin"
579            path = path_str
580
581            [key_value_store.relative]
582            type = "spin"
583            path = "custom.db"
584        };
585        let factors = TestFactors {
586            key_value: KeyValueFactor::new(),
587        };
588        let env = TestEnvironment::new(factors)
589            .extend_manifest(toml::toml! {
590                [component.test-component]
591                source = "does-not-exist.wasm"
592                key_value_stores = ["absolute", "relative"]
593            })
594            .runtime_config(
595                resolve_toml(runtime_config, tmp_dir.path().join("runtime-config.toml"))
596                    .unwrap()
597                    .runtime_config,
598            )?;
599        let mut state = env.build_instance_state().await?;
600
601        // Actually get a key since store creation is lazy
602        let store = state.key_value.open("absolute".to_owned()).await??;
603        let _ = state.key_value.get(store, "foo".to_owned()).await??;
604
605        let store = state.key_value.open("relative".to_owned()).await??;
606        let _ = state.key_value.get(store, "foo".to_owned()).await??;
607
608        // Check that the dbs have been created
609        assert!(absolute_path.exists());
610        assert!(relative_path.exists());
611        Ok(())
612    }
613
614    fn toml_resolver(toml: &toml::Table) -> TomlResolver<'_> {
615        TomlResolver::new(
616            toml,
617            None,
618            UserProvidedPath::Default,
619            UserProvidedPath::Default,
620        )
621    }
622
623    #[test]
624    fn dirs_are_resolved() {
625        define_test_factor!(sqlite: SqliteFactor);
626
627        let toml = toml::toml! {
628            state_dir = "/foo"
629            log_dir = "/bar"
630        };
631        resolve_toml(toml, "config.toml").unwrap();
632    }
633
634    #[test]
635    fn fails_to_resolve_with_unused_key() {
636        define_test_factor!(sqlite: SqliteFactor);
637
638        let toml = toml::toml! {
639            baz = "/baz"
640        };
641        // assert returns an error with value "unused runtime config key(s): local_app_dir"
642        let Err(e) = resolve_toml(toml, "config.toml") else {
643            panic!("Should not be able to resolve unknown key");
644        };
645        assert_eq!(e.to_string(), "unused runtime config key(s): baz");
646    }
647}