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_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::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig;
13use spin_factor_outbound_networking::OutboundNetworkingFactor;
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 runtime_config::toml::TomlKeyTracker, FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer,
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 if let Some(ty) = table.get("type").and_then(Value::as_str) {
79 summaries.push(format!("[llm_compute: {ty}"));
80 }
81 }
82 if !summaries.is_empty() {
83 let summaries = summaries.join(", ");
84 let from_path = runtime_config_path
85 .map(|path| format!("from {}", quoted_path(path)))
86 .unwrap_or_default();
87 println!("Using runtime config {summaries} {from_path}");
88 }
89 }
90}
91
92impl<T> ResolvedRuntimeConfig<T>
93where
94 T: for<'a, 'b> TryFrom<TomlRuntimeConfigSource<'a, 'b>>,
95 for<'a, 'b> <T as TryFrom<TomlRuntimeConfigSource<'a, 'b>>>::Error: Into<anyhow::Error>,
96{
97 pub fn from_file(
101 runtime_config_path: Option<&Path>,
102 local_app_dir: Option<PathBuf>,
103 provided_state_dir: UserProvidedPath,
104 provided_log_dir: UserProvidedPath,
105 ) -> anyhow::Result<Self> {
106 let toml = match runtime_config_path {
107 Some(runtime_config_path) => {
108 let file = std::fs::read_to_string(runtime_config_path).with_context(|| {
109 format!(
110 "failed to read runtime config file '{}'",
111 runtime_config_path.display()
112 )
113 })?;
114 toml::from_str(&file).with_context(|| {
115 format!(
116 "failed to parse runtime config file '{}' as toml",
117 runtime_config_path.display()
118 )
119 })?
120 }
121 None => Default::default(),
122 };
123 let toml_resolver =
124 TomlResolver::new(&toml, local_app_dir, provided_state_dir, provided_log_dir);
125
126 Self::new(toml_resolver, runtime_config_path)
127 }
128
129 pub fn new(
131 toml_resolver: TomlResolver<'_>,
132 runtime_config_path: Option<&Path>,
133 ) -> anyhow::Result<Self> {
134 let runtime_config_dir = runtime_config_path
135 .and_then(Path::parent)
136 .map(ToOwned::to_owned);
137 let state_dir = toml_resolver.state_dir()?;
138 let outbound_networking = runtime_config_dir
139 .clone()
140 .map(OutboundNetworkingSpinRuntimeConfig::new);
141 let key_value_resolver = key_value_config_resolver(runtime_config_dir, state_dir.clone());
142 let sqlite_resolver = sqlite_config_resolver(state_dir.clone())
143 .context("failed to resolve sqlite runtime config")?;
144
145 let toml = toml_resolver.toml();
146 let log_dir = toml_resolver.log_dir()?;
147 let max_instance_memory = toml_resolver.max_instance_memory()?;
148
149 let source = TomlRuntimeConfigSource::new(
150 toml_resolver,
151 &key_value_resolver,
152 outbound_networking.as_ref(),
153 &sqlite_resolver,
154 );
155
156 let runtime_config: T = source.try_into().map_err(Into::into)?;
160
161 Ok(Self {
162 runtime_config,
163 key_value_resolver,
164 sqlite_resolver,
165 state_dir,
166 log_dir,
167 max_instance_memory,
168 toml,
169 })
170 }
171
172 pub fn state_dir(&self) -> Option<PathBuf> {
174 self.state_dir.clone()
175 }
176
177 pub fn log_dir(&self) -> Option<PathBuf> {
179 self.log_dir.clone()
180 }
181
182 pub fn max_instance_memory(&self) -> Option<usize> {
184 self.max_instance_memory
185 }
186}
187
188#[derive(Clone, Debug)]
189pub struct TomlResolver<'a> {
191 table: TomlKeyTracker<'a>,
192 local_app_dir: Option<PathBuf>,
194 state_dir: UserProvidedPath,
196 log_dir: UserProvidedPath,
198}
199
200impl<'a> TomlResolver<'a> {
201 pub fn new(
203 table: &'a toml::Table,
204 local_app_dir: Option<PathBuf>,
205 state_dir: UserProvidedPath,
206 log_dir: UserProvidedPath,
207 ) -> Self {
208 Self {
209 table: TomlKeyTracker::new(table),
210 local_app_dir,
211 state_dir,
212 log_dir,
213 }
214 }
215
216 pub fn state_dir(&self) -> std::io::Result<Option<PathBuf>> {
220 let mut state_dir = self.state_dir.clone();
221 if matches!(state_dir, UserProvidedPath::Default) {
223 let from_toml =
224 self.table
225 .get("state_dir")
226 .and_then(|v| v.as_str())
227 .map(|toml_value| {
228 if toml_value.is_empty() {
229 UserProvidedPath::Unset
231 } else {
232 UserProvidedPath::Provided(PathBuf::from(toml_value))
234 }
235 });
236 state_dir = from_toml.unwrap_or(state_dir);
238 }
239
240 match (state_dir, &self.local_app_dir) {
241 (UserProvidedPath::Provided(p), _) => Ok(Some(std::path::absolute(p)?)),
242 (UserProvidedPath::Default, Some(local_app_dir)) => {
243 Ok(Some(local_app_dir.join(".spin")))
244 }
245 (UserProvidedPath::Default | UserProvidedPath::Unset, _) => Ok(None),
246 }
247 }
248
249 pub fn log_dir(&self) -> std::io::Result<Option<PathBuf>> {
253 let mut log_dir = self.log_dir.clone();
254 if matches!(log_dir, UserProvidedPath::Default) {
256 let from_toml = self
257 .table
258 .get("log_dir")
259 .and_then(|v| v.as_str())
260 .map(|toml_value| {
261 if toml_value.is_empty() {
262 UserProvidedPath::Unset
264 } else {
265 UserProvidedPath::Provided(PathBuf::from(toml_value))
267 }
268 });
269 log_dir = from_toml.unwrap_or(log_dir);
271 }
272
273 match log_dir {
274 UserProvidedPath::Provided(p) => Ok(Some(std::path::absolute(p)?)),
275 UserProvidedPath::Default => Ok(self.state_dir()?.map(|p| p.join("logs"))),
276 UserProvidedPath::Unset => Ok(None),
277 }
278 }
279
280 pub fn max_instance_memory(&self) -> anyhow::Result<Option<usize>> {
282 self.table
283 .get("max_instance_memory")
284 .and_then(|v| v.as_integer())
285 .map(|toml_value| toml_value.try_into())
286 .transpose()
287 .map_err(Into::into)
288 }
289
290 pub fn validate_all_keys_used(&self) -> spin_factors::Result<()> {
292 self.table.validate_all_keys_used()
293 }
294
295 fn toml(&self) -> toml::Table {
296 self.table.as_ref().clone()
297 }
298}
299
300pub struct TomlRuntimeConfigSource<'a, 'b> {
302 toml: TomlResolver<'b>,
303 key_value: &'a key_value::RuntimeConfigResolver,
304 outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
305 sqlite: &'a sqlite::RuntimeConfigResolver,
306}
307
308impl<'a, 'b> TomlRuntimeConfigSource<'a, 'b> {
309 pub fn new(
310 toml_resolver: TomlResolver<'b>,
311 key_value: &'a key_value::RuntimeConfigResolver,
312 outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
313 sqlite: &'a sqlite::RuntimeConfigResolver,
314 ) -> Self {
315 Self {
316 toml: toml_resolver,
317 key_value,
318 outbound_networking,
319 sqlite,
320 }
321 }
322}
323
324impl FactorRuntimeConfigSource<KeyValueFactor> for TomlRuntimeConfigSource<'_, '_> {
325 fn get_runtime_config(
326 &mut self,
327 ) -> anyhow::Result<Option<spin_factor_key_value::RuntimeConfig>> {
328 Ok(Some(self.key_value.resolve(Some(&self.toml.table))?))
329 }
330}
331
332impl FactorRuntimeConfigSource<OutboundNetworkingFactor> for TomlRuntimeConfigSource<'_, '_> {
333 fn get_runtime_config(
334 &mut self,
335 ) -> anyhow::Result<Option<<OutboundNetworkingFactor as spin_factors::Factor>::RuntimeConfig>>
336 {
337 let Some(tls) = self.outbound_networking else {
338 return Ok(None);
339 };
340 tls.config_from_table(&self.toml.table)
341 }
342}
343
344impl FactorRuntimeConfigSource<VariablesFactor> for TomlRuntimeConfigSource<'_, '_> {
345 fn get_runtime_config(
346 &mut self,
347 ) -> anyhow::Result<Option<<VariablesFactor as spin_factors::Factor>::RuntimeConfig>> {
348 Ok(Some(variables::runtime_config_from_toml(&self.toml.table)?))
349 }
350}
351
352impl FactorRuntimeConfigSource<OutboundPgFactor> for TomlRuntimeConfigSource<'_, '_> {
353 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
354 Ok(None)
355 }
356}
357
358impl FactorRuntimeConfigSource<OutboundMysqlFactor> for TomlRuntimeConfigSource<'_, '_> {
359 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
360 Ok(None)
361 }
362}
363
364impl FactorRuntimeConfigSource<LlmFactor> for TomlRuntimeConfigSource<'_, '_> {
365 fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_llm::RuntimeConfig>> {
366 llm::runtime_config_from_toml(&self.toml.table, self.toml.state_dir()?)
367 }
368}
369
370impl FactorRuntimeConfigSource<OutboundRedisFactor> for TomlRuntimeConfigSource<'_, '_> {
371 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
372 Ok(None)
373 }
374}
375
376impl FactorRuntimeConfigSource<WasiFactor> for TomlRuntimeConfigSource<'_, '_> {
377 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
378 Ok(None)
379 }
380}
381
382impl FactorRuntimeConfigSource<OutboundHttpFactor> for TomlRuntimeConfigSource<'_, '_> {
383 fn get_runtime_config(
384 &mut self,
385 ) -> anyhow::Result<Option<<OutboundHttpFactor as spin_factors::Factor>::RuntimeConfig>> {
386 spin_factor_outbound_http::runtime_config::spin::config_from_table(&self.toml.table)
387 }
388}
389
390impl FactorRuntimeConfigSource<OutboundMqttFactor> for TomlRuntimeConfigSource<'_, '_> {
391 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
392 Ok(None)
393 }
394}
395
396impl FactorRuntimeConfigSource<SqliteFactor> for TomlRuntimeConfigSource<'_, '_> {
397 fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_sqlite::RuntimeConfig>> {
398 Ok(Some(self.sqlite.resolve(&self.toml.table)?))
399 }
400}
401
402impl FactorRuntimeConfigSource<OtelFactor> for TomlRuntimeConfigSource<'_, '_> {
403 fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
404 Ok(None)
405 }
406}
407
408impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> {
409 fn finalize(&mut self) -> anyhow::Result<()> {
410 Ok(self.toml.validate_all_keys_used()?)
411 }
412}
413
414const DEFAULT_KEY_VALUE_STORE_LABEL: &str = "default";
415
416pub fn key_value_config_resolver(
422 local_store_base_path: Option<PathBuf>,
423 default_store_base_path: Option<PathBuf>,
424) -> key_value::RuntimeConfigResolver {
425 let mut key_value = key_value::RuntimeConfigResolver::new();
426
427 key_value
430 .register_store_type(spin_key_value_spin::SpinKeyValueStore::new(
431 local_store_base_path.clone(),
432 ))
433 .unwrap();
434 key_value
435 .register_store_type(spin_key_value_redis::RedisKeyValueStore::new())
436 .unwrap();
437 key_value
438 .register_store_type(spin_key_value_azure::AzureKeyValueStore::new(None))
439 .unwrap();
440 key_value
441 .register_store_type(spin_key_value_aws::AwsDynamoKeyValueStore::new())
442 .unwrap();
443
444 let default_store_path = default_store_base_path.map(|p| p.join(DEFAULT_SPIN_STORE_FILENAME));
446 key_value
448 .add_default_store::<SpinKeyValueStore>(
449 DEFAULT_KEY_VALUE_STORE_LABEL,
450 SpinKeyValueRuntimeConfig::new(default_store_path),
451 )
452 .unwrap();
453
454 key_value
455}
456
457const DEFAULT_SPIN_STORE_FILENAME: &str = "sqlite_key_value.db";
459
460fn sqlite_config_resolver(
465 default_database_dir: Option<PathBuf>,
466) -> anyhow::Result<sqlite::RuntimeConfigResolver> {
467 let local_database_dir =
468 std::env::current_dir().context("failed to get current working directory")?;
469 Ok(sqlite::RuntimeConfigResolver::new(
470 default_database_dir,
471 local_database_dir,
472 ))
473}
474
475#[cfg(test)]
476mod tests {
477 use std::{collections::HashMap, sync::Arc};
478
479 use spin_factors::RuntimeFactors;
480 use spin_factors_test::TestEnvironment;
481
482 use super::*;
483
484 macro_rules! define_test_factor {
486 ($field:ident : $factor:ty) => {
487 #[derive(RuntimeFactors)]
488 #[allow(unused)]
489 struct TestFactors {
490 $field: $factor,
491 }
492 impl TryFrom<TomlRuntimeConfigSource<'_, '_>> for TestFactorsRuntimeConfig {
493 type Error = anyhow::Error;
494
495 fn try_from(value: TomlRuntimeConfigSource<'_, '_>) -> Result<Self, Self::Error> {
496 Self::from_source(value)
497 }
498 }
499 fn resolve_toml(
500 toml: toml::Table,
501 path: impl AsRef<std::path::Path>,
502 ) -> anyhow::Result<ResolvedRuntimeConfig<TestFactorsRuntimeConfig>> {
503 ResolvedRuntimeConfig::<TestFactorsRuntimeConfig>::new(
504 toml_resolver(&toml),
505 Some(path.as_ref()),
506 )
507 }
508 };
509 }
510
511 #[test]
512 fn sqlite_is_configured_correctly() {
513 define_test_factor!(sqlite: SqliteFactor);
514
515 impl TestFactorsRuntimeConfig {
516 fn connection_creators(
518 &self,
519 ) -> &HashMap<String, Arc<dyn spin_factor_sqlite::ConnectionCreator>> {
520 &self.sqlite.as_ref().unwrap().connection_creators
521 }
522
523 fn configured_labels(&self) -> Vec<&str> {
525 let mut configured_labels = self
526 .connection_creators()
527 .keys()
528 .map(|s| s.as_str())
529 .collect::<Vec<_>>();
530 configured_labels.sort();
532 configured_labels
533 }
534 }
535
536 let toml = toml::toml! {
538 [sqlite_database.foo]
539 type = "spin"
540 };
541 assert_eq!(
542 resolve_toml(toml, ".")
543 .unwrap()
544 .runtime_config
545 .configured_labels(),
546 vec!["default", "foo"]
547 );
548
549 let toml = toml::Table::new();
551 let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
552 assert_eq!(runtime_config.configured_labels(), vec!["default"]);
553 }
554
555 #[test]
556 fn key_value_is_configured_correctly() {
557 define_test_factor!(key_value: KeyValueFactor);
558 impl TestFactorsRuntimeConfig {
559 fn has_store_manager(&self, label: &str) -> bool {
561 self.key_value.as_ref().unwrap().has_store_manager(label)
562 }
563 }
564
565 let toml = toml::toml! {
567 [key_value_store.foo]
568 type = "spin"
569 };
570 let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
571 assert!(["default", "foo"]
572 .iter()
573 .all(|label| runtime_config.has_store_manager(label)));
574 }
575
576 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
577 async fn custom_spin_key_value_works_with_custom_paths() -> anyhow::Result<()> {
578 use spin_world::v2::key_value::HostStore;
579 define_test_factor!(key_value: KeyValueFactor);
580 let tmp_dir = tempfile::TempDir::with_prefix("example")?;
581 let absolute_path = tmp_dir.path().join("foo/custom.db");
582 let relative_path = tmp_dir.path().join("custom.db");
583 assert!(!absolute_path.exists());
585 assert!(!relative_path.exists());
586
587 let path_str = absolute_path.to_str().unwrap();
588 let runtime_config = toml::toml! {
589 [key_value_store.absolute]
590 type = "spin"
591 path = path_str
592
593 [key_value_store.relative]
594 type = "spin"
595 path = "custom.db"
596 };
597 let factors = TestFactors {
598 key_value: KeyValueFactor::new(),
599 };
600 let env = TestEnvironment::new(factors)
601 .extend_manifest(toml::toml! {
602 [component.test-component]
603 source = "does-not-exist.wasm"
604 key_value_stores = ["absolute", "relative"]
605 })
606 .runtime_config(
607 resolve_toml(runtime_config, tmp_dir.path().join("runtime-config.toml"))
608 .unwrap()
609 .runtime_config,
610 )?;
611 let mut state = env.build_instance_state().await?;
612
613 let store = state.key_value.open("absolute".to_owned()).await??;
615 let _ = state.key_value.get(store, "foo".to_owned()).await??;
616
617 let store = state.key_value.open("relative".to_owned()).await??;
618 let _ = state.key_value.get(store, "foo".to_owned()).await??;
619
620 assert!(absolute_path.exists());
622 assert!(relative_path.exists());
623 Ok(())
624 }
625
626 fn toml_resolver(toml: &toml::Table) -> TomlResolver<'_> {
627 TomlResolver::new(
628 toml,
629 None,
630 UserProvidedPath::Default,
631 UserProvidedPath::Default,
632 )
633 }
634
635 #[test]
636 fn dirs_are_resolved() {
637 define_test_factor!(sqlite: SqliteFactor);
638
639 let toml = toml::toml! {
640 state_dir = "/foo"
641 log_dir = "/bar"
642 };
643 resolve_toml(toml, "config.toml").unwrap();
644 }
645
646 #[test]
647 fn fails_to_resolve_with_unused_key() {
648 define_test_factor!(sqlite: SqliteFactor);
649
650 let toml = toml::toml! {
651 baz = "/baz"
652 };
653 let Err(e) = resolve_toml(toml, "config.toml") else {
655 panic!("Should not be able to resolve unknown key");
656 };
657 assert_eq!(e.to_string(), "unused runtime config key(s): baz");
658 }
659}