1use std::path::{Path, PathBuf};
2
3use anyhow::Context as _;
4use spin_common::ui::quoted_path;
5use spin_factor_key_value::KeyValueFactor;
6use spin_factor_key_value::runtime_config::spin::{self as key_value};
7use spin_factor_llm::{LlmFactor, spin as llm};
8use spin_factor_otel::OtelFactor;
9use spin_factor_outbound_http::OutboundHttpFactor;
10use spin_factor_outbound_mqtt::OutboundMqttFactor;
11use spin_factor_outbound_mysql::OutboundMysqlFactor;
12use spin_factor_outbound_networking::OutboundNetworkingFactor;
13use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig;
14use spin_factor_outbound_pg::OutboundPgFactor;
15use spin_factor_outbound_redis::OutboundRedisFactor;
16use spin_factor_sqlite::SqliteFactor;
17use spin_factor_variables::VariablesFactor;
18use spin_factor_wasi::WasiFactor;
19use spin_factors::runtime_config::toml::GetTomlValue as _;
20use spin_factors::{
21 FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer, runtime_config::toml::TomlKeyTracker,
22};
23use spin_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore};
24use spin_sqlite as sqlite;
25use spin_trigger::cli::UserProvidedPath;
26use toml::Value;
27
28pub mod variables;
29
30pub const DEFAULT_STATE_DIR: &str = ".spin";
32
33pub struct ResolvedRuntimeConfig<T> {
37 pub runtime_config: T,
39 pub key_value_resolver: key_value::RuntimeConfigResolver,
41 pub sqlite_resolver: sqlite::RuntimeConfigResolver,
43 pub state_dir: Option<PathBuf>,
47 pub log_dir: Option<PathBuf>,
51 pub max_instance_memory: Option<usize>,
53 pub toml: toml::Table,
55}
56
57impl<T> ResolvedRuntimeConfig<T> {
58 pub fn summarize(&self, runtime_config_path: Option<&Path>) {
59 let summarize_labeled_typed_tables = |key| {
60 let mut summaries = vec![];
61 if let Some(tables) = self.toml.get(key).and_then(Value::as_table) {
62 for (label, config) in tables {
63 if let Some(ty) = config.get("type").and_then(Value::as_str) {
64 summaries.push(format!("[{key}.{label}: {ty}]"))
65 }
66 }
67 }
68 summaries
69 };
70
71 let mut summaries = vec![];
72 summaries.extend(summarize_labeled_typed_tables("key_value_store"));
74 summaries.extend(summarize_labeled_typed_tables("sqlite_database"));
76 if let Some(table) = self.toml.get("llm_compute").and_then(Value::as_table)
78 && let Some(ty) = table.get("type").and_then(Value::as_str)
79 {
80 summaries.push(format!("[llm_compute: {ty}"));
81 }
82 if let Some(table) = self
84 .toml
85 .get("outbound_networking")
86 .and_then(Value::as_table)
87 && let Some(max) = table
88 .get("max_total_connections")
89 .and_then(Value::as_integer)
90 {
91 summaries.push(format!(
92 "[outbound_networking: max_total_connections={max}]"
93 ));
94 }
95 for key in [
97 "outbound_redis",
98 "outbound_pg",
99 "outbound_mysql",
100 "outbound_mqtt",
101 "outbound_http",
102 ] {
103 if let Some(table) = self.toml.get(key).and_then(Value::as_table)
104 && let Some(max) = table.get("max_connections").and_then(Value::as_integer)
105 {
106 summaries.push(format!("[{key}: max_connections={max}]"));
107 }
108 }
109 if let Some(table) = self.toml.get("outbound_http").and_then(Value::as_table)
111 && let Some(max) = table
112 .get("max_concurrent_requests")
113 .and_then(Value::as_integer)
114 {
115 summaries.push(format!(
116 "[outbound_http: max_concurrent_requests={max} (deprecated, use max_connections)]"
117 ));
118 }
119 if !summaries.is_empty() {
120 let summaries = summaries.join(", ");
121 let from_path = runtime_config_path
122 .map(|path| format!("from {}", quoted_path(path)))
123 .unwrap_or_default();
124 eprintln!("Using runtime config {summaries} {from_path}");
125 }
126 }
127}
128
129impl<T> ResolvedRuntimeConfig<T>
130where
131 T: for<'a, 'b> TryFrom<TomlRuntimeConfigSource<'a, 'b>>,
132 for<'a, 'b> <T as TryFrom<TomlRuntimeConfigSource<'a, 'b>>>::Error: Into<anyhow::Error>,
133{
134 pub fn from_file(
138 runtime_config_path: Option<&Path>,
139 local_app_dir: Option<PathBuf>,
140 provided_state_dir: UserProvidedPath,
141 provided_log_dir: UserProvidedPath,
142 ) -> anyhow::Result<Self> {
143 let toml = match runtime_config_path {
144 Some(runtime_config_path) => {
145 let file = std::fs::read_to_string(runtime_config_path).with_context(|| {
146 format!(
147 "failed to read runtime config file '{}'",
148 runtime_config_path.display()
149 )
150 })?;
151 toml::from_str(&file).with_context(|| {
152 format!(
153 "failed to parse runtime config file '{}' as toml",
154 runtime_config_path.display()
155 )
156 })?
157 }
158 None => Default::default(),
159 };
160 let toml_resolver =
161 TomlResolver::new(&toml, local_app_dir, provided_state_dir, provided_log_dir);
162
163 Self::new(toml_resolver, runtime_config_path)
164 }
165
166 pub fn new(
168 toml_resolver: TomlResolver<'_>,
169 runtime_config_path: Option<&Path>,
170 ) -> anyhow::Result<Self> {
171 let runtime_config_dir = runtime_config_path
172 .and_then(Path::parent)
173 .map(ToOwned::to_owned);
174 let state_dir = toml_resolver.state_dir()?;
175 let outbound_networking = runtime_config_dir
176 .clone()
177 .map(OutboundNetworkingSpinRuntimeConfig::new);
178 let key_value_resolver = key_value_config_resolver(runtime_config_dir, state_dir.clone());
179 let sqlite_resolver = sqlite_config_resolver(state_dir.clone())
180 .context("failed to resolve sqlite runtime config")?;
181
182 let toml = toml_resolver.toml();
183 let log_dir = toml_resolver.log_dir()?;
184 let max_instance_memory = toml_resolver.max_instance_memory()?;
185
186 let source = TomlRuntimeConfigSource::new(
187 toml_resolver,
188 &key_value_resolver,
189 outbound_networking.as_ref(),
190 &sqlite_resolver,
191 );
192
193 let runtime_config: T = source.try_into().map_err(Into::into)?;
197
198 Ok(Self {
199 runtime_config,
200 key_value_resolver,
201 sqlite_resolver,
202 state_dir,
203 log_dir,
204 max_instance_memory,
205 toml,
206 })
207 }
208
209 pub fn state_dir(&self) -> Option<PathBuf> {
211 self.state_dir.clone()
212 }
213
214 pub fn log_dir(&self) -> Option<PathBuf> {
216 self.log_dir.clone()
217 }
218
219 pub fn max_instance_memory(&self) -> Option<usize> {
221 self.max_instance_memory
222 }
223}
224
225#[derive(Clone, Debug)]
226pub struct TomlResolver<'a> {
228 table: TomlKeyTracker<'a>,
229 local_app_dir: Option<PathBuf>,
231 state_dir: UserProvidedPath,
233 log_dir: UserProvidedPath,
235}
236
237impl<'a> TomlResolver<'a> {
238 pub fn new(
240 table: &'a toml::Table,
241 local_app_dir: Option<PathBuf>,
242 state_dir: UserProvidedPath,
243 log_dir: UserProvidedPath,
244 ) -> Self {
245 Self {
246 table: TomlKeyTracker::new(table),
247 local_app_dir,
248 state_dir,
249 log_dir,
250 }
251 }
252
253 pub fn state_dir(&self) -> std::io::Result<Option<PathBuf>> {
257 let mut state_dir = self.state_dir.clone();
258 if matches!(state_dir, UserProvidedPath::Default) {
260 let from_toml =
261 self.table
262 .get("state_dir")
263 .and_then(|v| v.as_str())
264 .map(|toml_value| {
265 if toml_value.is_empty() {
266 UserProvidedPath::Unset
268 } else {
269 UserProvidedPath::Provided(PathBuf::from(toml_value))
271 }
272 });
273 state_dir = from_toml.unwrap_or(state_dir);
275 }
276
277 match (state_dir, &self.local_app_dir) {
278 (UserProvidedPath::Provided(p), _) => Ok(Some(std::path::absolute(p)?)),
279 (UserProvidedPath::Default, Some(local_app_dir)) => {
280 Ok(Some(local_app_dir.join(".spin")))
281 }
282 (UserProvidedPath::Default | UserProvidedPath::Unset, _) => Ok(None),
283 }
284 }
285
286 pub fn log_dir(&self) -> std::io::Result<Option<PathBuf>> {
290 let mut log_dir = self.log_dir.clone();
291 if matches!(log_dir, UserProvidedPath::Default) {
293 let from_toml = self
294 .table
295 .get("log_dir")
296 .and_then(|v| v.as_str())
297 .map(|toml_value| {
298 if toml_value.is_empty() {
299 UserProvidedPath::Unset
301 } else {
302 UserProvidedPath::Provided(PathBuf::from(toml_value))
304 }
305 });
306 log_dir = from_toml.unwrap_or(log_dir);
308 }
309
310 match log_dir {
311 UserProvidedPath::Provided(p) => Ok(Some(std::path::absolute(p)?)),
312 UserProvidedPath::Default => Ok(self.state_dir()?.map(|p| p.join("logs"))),
313 UserProvidedPath::Unset => Ok(None),
314 }
315 }
316
317 pub fn max_instance_memory(&self) -> anyhow::Result<Option<usize>> {
319 self.table
320 .get("max_instance_memory")
321 .and_then(|v| v.as_integer())
322 .map(|toml_value| toml_value.try_into())
323 .transpose()
324 .map_err(Into::into)
325 }
326
327 pub fn validate_all_keys_used(&self) -> spin_factors::Result<()> {
329 self.table.validate_all_keys_used()
330 }
331
332 fn toml(&self) -> toml::Table {
333 self.table.as_ref().clone()
334 }
335}
336
337pub struct TomlRuntimeConfigSource<'a, 'b> {
339 toml: TomlResolver<'b>,
340 key_value: &'a key_value::RuntimeConfigResolver,
341 outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
342 sqlite: &'a sqlite::RuntimeConfigResolver,
343}
344
345impl<'a, 'b> TomlRuntimeConfigSource<'a, 'b> {
346 pub fn new(
347 toml_resolver: TomlResolver<'b>,
348 key_value: &'a key_value::RuntimeConfigResolver,
349 outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
350 sqlite: &'a sqlite::RuntimeConfigResolver,
351 ) -> Self {
352 Self {
353 toml: toml_resolver,
354 key_value,
355 outbound_networking,
356 sqlite,
357 }
358 }
359}
360
361impl FactorRuntimeConfigSource<KeyValueFactor> for TomlRuntimeConfigSource<'_, '_> {
362 fn get_runtime_config(
363 &mut self,
364 ) -> anyhow::Result<Option<spin_factor_key_value::RuntimeConfig>> {
365 Ok(Some(self.key_value.resolve(Some(&self.toml.table))?))
366 }
367}
368
369impl FactorRuntimeConfigSource<OutboundNetworkingFactor> for TomlRuntimeConfigSource<'_, '_> {
370 fn get_runtime_config(
371 &mut self,
372 ) -> anyhow::Result<Option<<OutboundNetworkingFactor as spin_factors::Factor>::RuntimeConfig>>
373 {
374 let Some(tls) = self.outbound_networking else {
375 return Ok(None);
376 };
377 tls.config_from_table(&self.toml.table)
378 }
379}
380
381impl FactorRuntimeConfigSource<VariablesFactor> for TomlRuntimeConfigSource<'_, '_> {
382 fn get_runtime_config(
383 &mut self,
384 ) -> anyhow::Result<Option<<VariablesFactor as spin_factors::Factor>::RuntimeConfig>> {
385 Ok(Some(variables::runtime_config_from_toml(&self.toml.table)?))
386 }
387}
388
389impl FactorRuntimeConfigSource<OutboundPgFactor> for TomlRuntimeConfigSource<'_, '_> {
390 fn get_runtime_config(
391 &mut self,
392 ) -> anyhow::Result<Option<<OutboundPgFactor as spin_factors::Factor>::RuntimeConfig>> {
393 spin_factor_outbound_pg::runtime_config::spin::config_from_table(&self.toml.table)
394 }
395}
396
397impl FactorRuntimeConfigSource<OutboundMysqlFactor> for TomlRuntimeConfigSource<'_, '_> {
398 fn get_runtime_config(
399 &mut self,
400 ) -> anyhow::Result<Option<<OutboundMysqlFactor as spin_factors::Factor>::RuntimeConfig>> {
401 spin_factor_outbound_mysql::runtime_config::spin::config_from_table(&self.toml.table)
402 }
403}
404
405impl FactorRuntimeConfigSource<LlmFactor> for TomlRuntimeConfigSource<'_, '_> {
406 fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_llm::RuntimeConfig>> {
407 llm::runtime_config_from_toml(&self.toml.table, self.toml.state_dir()?)
408 }
409}
410
411impl FactorRuntimeConfigSource<OutboundRedisFactor> for TomlRuntimeConfigSource<'_, '_> {
412 fn get_runtime_config(
413 &mut self,
414 ) -> anyhow::Result<Option<<OutboundRedisFactor as spin_factors::Factor>::RuntimeConfig>> {
415 spin_factor_outbound_redis::runtime_config::spin::config_from_table(&self.toml.table)
416 }
417}
418
419impl FactorRuntimeConfigSource<WasiFactor> for TomlRuntimeConfigSource<'_, '_> {
420 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
421 Ok(None)
422 }
423}
424
425impl FactorRuntimeConfigSource<OutboundHttpFactor> for TomlRuntimeConfigSource<'_, '_> {
426 fn get_runtime_config(
427 &mut self,
428 ) -> anyhow::Result<Option<<OutboundHttpFactor as spin_factors::Factor>::RuntimeConfig>> {
429 spin_factor_outbound_http::runtime_config::spin::config_from_table(&self.toml.table)
430 }
431}
432
433impl FactorRuntimeConfigSource<OutboundMqttFactor> for TomlRuntimeConfigSource<'_, '_> {
434 fn get_runtime_config(
435 &mut self,
436 ) -> anyhow::Result<Option<<OutboundMqttFactor as spin_factors::Factor>::RuntimeConfig>> {
437 spin_factor_outbound_mqtt::runtime_config::spin::config_from_table(&self.toml.table)
438 }
439}
440
441impl FactorRuntimeConfigSource<SqliteFactor> for TomlRuntimeConfigSource<'_, '_> {
442 fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_sqlite::RuntimeConfig>> {
443 Ok(Some(self.sqlite.resolve(&self.toml.table)?))
444 }
445}
446
447impl FactorRuntimeConfigSource<OtelFactor> for TomlRuntimeConfigSource<'_, '_> {
448 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
449 Ok(None)
450 }
451}
452
453impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> {
454 fn finalize(&mut self) -> anyhow::Result<()> {
455 Ok(self.toml.validate_all_keys_used()?)
456 }
457}
458
459const DEFAULT_KEY_VALUE_STORE_LABEL: &str = "default";
460
461pub fn key_value_config_resolver(
467 local_store_base_path: Option<PathBuf>,
468 default_store_base_path: Option<PathBuf>,
469) -> key_value::RuntimeConfigResolver {
470 let mut key_value = key_value::RuntimeConfigResolver::new();
471
472 key_value
475 .register_store_type(spin_key_value_spin::SpinKeyValueStore::new(
476 local_store_base_path.clone(),
477 ))
478 .unwrap();
479 key_value
480 .register_store_type(spin_key_value_redis::RedisKeyValueStore::new())
481 .unwrap();
482 key_value
483 .register_store_type(spin_key_value_azure::AzureKeyValueStore::new(None))
484 .unwrap();
485 key_value
486 .register_store_type(spin_key_value_aws::AwsDynamoKeyValueStore::new())
487 .unwrap();
488
489 let default_store_path = default_store_base_path.map(|p| p.join(DEFAULT_SPIN_STORE_FILENAME));
491 key_value
493 .add_default_store::<SpinKeyValueStore>(
494 DEFAULT_KEY_VALUE_STORE_LABEL,
495 SpinKeyValueRuntimeConfig::new(default_store_path),
496 )
497 .unwrap();
498
499 key_value
500}
501
502const DEFAULT_SPIN_STORE_FILENAME: &str = "sqlite_key_value.db";
504
505fn sqlite_config_resolver(
510 default_database_dir: Option<PathBuf>,
511) -> anyhow::Result<sqlite::RuntimeConfigResolver> {
512 let local_database_dir =
513 std::env::current_dir().context("failed to get current working directory")?;
514 Ok(sqlite::RuntimeConfigResolver::new(
515 default_database_dir,
516 local_database_dir,
517 ))
518}
519
520#[cfg(test)]
521mod tests {
522 use std::{collections::HashMap, sync::Arc};
523
524 use spin_factors::RuntimeFactors;
525 use spin_factors_test::TestEnvironment;
526
527 use super::*;
528
529 macro_rules! define_test_factor {
531 ($field:ident : $factor:ty) => {
532 #[derive(RuntimeFactors)]
533 #[allow(unused)]
534 struct TestFactors {
535 $field: $factor,
536 }
537 impl TryFrom<TomlRuntimeConfigSource<'_, '_>> for TestFactorsRuntimeConfig {
538 type Error = anyhow::Error;
539
540 fn try_from(value: TomlRuntimeConfigSource<'_, '_>) -> Result<Self, Self::Error> {
541 Self::from_source(value)
542 }
543 }
544 fn resolve_toml(
545 toml: toml::Table,
546 path: impl AsRef<std::path::Path>,
547 ) -> anyhow::Result<ResolvedRuntimeConfig<TestFactorsRuntimeConfig>> {
548 ResolvedRuntimeConfig::<TestFactorsRuntimeConfig>::new(
549 toml_resolver(&toml),
550 Some(path.as_ref()),
551 )
552 }
553 };
554 }
555
556 #[test]
557 fn sqlite_is_configured_correctly() {
558 define_test_factor!(sqlite: SqliteFactor);
559
560 impl TestFactorsRuntimeConfig {
561 fn connection_creators(
563 &self,
564 ) -> &HashMap<String, Arc<dyn spin_factor_sqlite::ConnectionCreator>> {
565 &self.sqlite.as_ref().unwrap().connection_creators
566 }
567
568 fn configured_labels(&self) -> Vec<&str> {
570 let mut configured_labels = self
571 .connection_creators()
572 .keys()
573 .map(|s| s.as_str())
574 .collect::<Vec<_>>();
575 configured_labels.sort();
577 configured_labels
578 }
579 }
580
581 let toml = toml::toml! {
583 [sqlite_database.foo]
584 type = "spin"
585 };
586 assert_eq!(
587 resolve_toml(toml, ".")
588 .unwrap()
589 .runtime_config
590 .configured_labels(),
591 vec!["default", "foo"]
592 );
593
594 let toml = toml::Table::new();
596 let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
597 assert_eq!(runtime_config.configured_labels(), vec!["default"]);
598 }
599
600 #[test]
601 fn key_value_is_configured_correctly() {
602 define_test_factor!(key_value: KeyValueFactor);
603 impl TestFactorsRuntimeConfig {
604 fn has_store_manager(&self, label: &str) -> bool {
606 self.key_value.as_ref().unwrap().has_store_manager(label)
607 }
608 }
609
610 let toml = toml::toml! {
612 [key_value_store.foo]
613 type = "spin"
614 };
615 let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
616 assert!(
617 ["default", "foo"]
618 .iter()
619 .all(|label| runtime_config.has_store_manager(label))
620 );
621 }
622
623 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
624 async fn custom_spin_key_value_works_with_custom_paths() -> anyhow::Result<()> {
625 use spin_world::v2::key_value::HostStore;
626 define_test_factor!(key_value: KeyValueFactor);
627 let tmp_dir = tempfile::TempDir::with_prefix("example")?;
628 let absolute_path = tmp_dir.path().join("foo/custom.db");
629 let relative_path = tmp_dir.path().join("custom.db");
630 assert!(!absolute_path.exists());
632 assert!(!relative_path.exists());
633
634 let path_str = absolute_path.to_str().unwrap();
635 let runtime_config = toml::toml! {
636 [key_value_store.absolute]
637 type = "spin"
638 path = path_str
639
640 [key_value_store.relative]
641 type = "spin"
642 path = "custom.db"
643 };
644 let factors = TestFactors {
645 key_value: KeyValueFactor::new(),
646 };
647 let env = TestEnvironment::new(factors)
648 .extend_manifest(toml::toml! {
649 [component.test-component]
650 source = "does-not-exist.wasm"
651 key_value_stores = ["absolute", "relative"]
652 })
653 .runtime_config(
654 resolve_toml(runtime_config, tmp_dir.path().join("runtime-config.toml"))
655 .unwrap()
656 .runtime_config,
657 )?;
658 let mut state = env.build_instance_state().await?;
659
660 let store = state.key_value.open("absolute".to_owned()).await??;
662 let _ = state.key_value.get(store, "foo".to_owned()).await??;
663
664 let store = state.key_value.open("relative".to_owned()).await??;
665 let _ = state.key_value.get(store, "foo".to_owned()).await??;
666
667 assert!(absolute_path.exists());
669 assert!(relative_path.exists());
670 Ok(())
671 }
672
673 fn toml_resolver(toml: &toml::Table) -> TomlResolver<'_> {
674 TomlResolver::new(
675 toml,
676 None,
677 UserProvidedPath::Default,
678 UserProvidedPath::Default,
679 )
680 }
681
682 #[test]
683 fn dirs_are_resolved() {
684 define_test_factor!(sqlite: SqliteFactor);
685
686 let toml = toml::toml! {
687 state_dir = "/foo"
688 log_dir = "/bar"
689 };
690 resolve_toml(toml, "config.toml").unwrap();
691 }
692
693 #[test]
694 fn fails_to_resolve_with_unused_key() {
695 define_test_factor!(sqlite: SqliteFactor);
696
697 let toml = toml::toml! {
698 baz = "/baz"
699 };
700 let Err(e) = resolve_toml(toml, "config.toml") else {
702 panic!("Should not be able to resolve unknown key");
703 };
704 assert_eq!(e.to_string(), "unused runtime config key(s): baz");
705 }
706}