Repository: fermyon/spin
Pull request #1288 (2023-03-16 17:29:33)

[Backport v1.0] Update runtime config system and add `spin up --state-dir` flag
Backporting #1245 to v1.0


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 0ad55536..6e6ffe22 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4694,7 +4694,6 @@ dependencies = [
  "ctrlc",
  "dirs 4.0.0",
  "futures",
- "once_cell",
  "outbound-http",
  "outbound-mysql",
  "outbound-pg",
diff --git a/crates/config/src/provider/vault.rs b/crates/config/src/provider/vault.rs
index afc8c08b..a0f901bd 100644
--- a/crates/config/src/provider/vault.rs
+++ b/crates/config/src/provider/vault.rs
@@ -23,13 +23,13 @@ impl VaultProvider {
         url: impl Into<String>,
         token: impl Into<String>,
         mount: impl Into<String>,
-        prefix: Option<String>,
+        prefix: Option<impl Into<String>>,
     ) -> Self {
         Self {
             url: url.into(),
             token: token.into(),
             mount: mount.into(),
-            prefix,
+            prefix: prefix.map(Into::into),
         }
     }
 }
diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs
index e1c47942..fe731567 100644
--- a/crates/http/src/lib.rs
+++ b/crates/http/src/lib.rs
@@ -176,7 +176,7 @@ impl TriggerExecutor for HttpTrigger {
         // Print startup messages
         let scheme = if tls.is_some() { "https" } else { "http" };
         let base_url = format!("{}://{:?}", scheme, listen_addr);
-        println!("Serving {}", base_url);
+        println!("\nServing {}", base_url);
         log::info!("Serving {}", base_url);
 
         println!("Available Routes:");
diff --git a/crates/key-value-sqlite/src/lib.rs b/crates/key-value-sqlite/src/lib.rs
index 8900dc6c..9f7290b7 100644
--- a/crates/key-value-sqlite/src/lib.rs
+++ b/crates/key-value-sqlite/src/lib.rs
@@ -34,14 +34,8 @@ impl StoreManager for KeyValueSqlite {
         let connection = task::block_in_place(|| {
             self.connection.get_or_try_init(|| {
                 let connection = match &self.location {
-                    DatabaseLocation::InMemory => {
-                        println!("Using in-memory key-value store");
-                        Connection::open_in_memory()
-                    }
-                    DatabaseLocation::Path(path) => {
-                        println!("Using {} for key-value store", path.display());
-                        Connection::open(path)
-                    }
+                    DatabaseLocation::InMemory => Connection::open_in_memory(),
+                    DatabaseLocation::Path(path) => Connection::open(path),
                 }
                 .map_err(log_error)?;
 
@@ -156,15 +150,10 @@ mod test {
                 .into_iter()
                 .map(ToOwned::to_owned)
                 .collect(),
-            Arc::new(DelegatingStoreManager::new(
-                [(
-                    "default".to_owned(),
-                    Arc::new(KeyValueSqlite::new(DatabaseLocation::InMemory))
-                        as Arc<dyn StoreManager>,
-                )]
-                .into_iter()
-                .collect(),
-            )),
+            Arc::new(DelegatingStoreManager::new([(
+                "default".to_owned(),
+                Arc::new(KeyValueSqlite::new(DatabaseLocation::InMemory)) as _,
+            )])),
         );
 
         assert!(matches!(
diff --git a/crates/key-value/src/host_component.rs b/crates/key-value/src/host_component.rs
index aaa023fe..1d81ff17 100644
--- a/crates/key-value/src/host_component.rs
+++ b/crates/key-value/src/host_component.rs
@@ -58,15 +58,18 @@ impl HostComponent for KeyValueComponent {
 
 impl DynamicHostComponent for KeyValueComponent {
     fn update_data(&self, data: &mut Self::Data, component: &AppComponent) -> anyhow::Result<()> {
+        let key_value_stores = component_key_value_stores(component)?;
         data.init(
-            component
-                .get_metadata::<Vec<String>>(KEY_VALUE_STORES_METADATA_KEY)?
-                .unwrap_or_default()
-                .into_iter()
-                .collect(),
+            key_value_stores.into_iter().collect(),
             self.manager.get(component),
         );
 
         Ok(())
     }
 }
+
+pub fn component_key_value_stores(component: &AppComponent) -> anyhow::Result<Vec<String>> {
+    Ok(component
+        .get_metadata(KEY_VALUE_STORES_METADATA_KEY)?
+        .unwrap_or_default())
+}
diff --git a/crates/key-value/src/lib.rs b/crates/key-value/src/lib.rs
index 91fd61a7..d17f5db3 100644
--- a/crates/key-value/src/lib.rs
+++ b/crates/key-value/src/lib.rs
@@ -7,7 +7,7 @@ mod host_component;
 mod table;
 mod util;
 
-pub use host_component::{manager, KeyValueComponent};
+pub use host_component::{component_key_value_stores, manager, KeyValueComponent};
 pub use util::{CachingStoreManager, DelegatingStoreManager, EmptyStoreManager};
 
 const DEFAULT_STORE_TABLE_CAPACITY: u32 = 256;
diff --git a/crates/key-value/src/util.rs b/crates/key-value/src/util.rs
index e97a9cde..5c5bc3eb 100644
--- a/crates/key-value/src/util.rs
+++ b/crates/key-value/src/util.rs
@@ -28,7 +28,8 @@ pub struct DelegatingStoreManager {
 }
 
 impl DelegatingStoreManager {
-    pub fn new(delegates: HashMap<String, Arc<dyn StoreManager>>) -> Self {
+    pub fn new(delegates: impl IntoIterator<Item = (String, Arc<dyn StoreManager>)>) -> Self {
+        let delegates = delegates.into_iter().collect();
         Self { delegates }
     }
 }
diff --git a/crates/trigger/Cargo.toml b/crates/trigger/Cargo.toml
index 3dd85380..671f10c1 100644
--- a/crates/trigger/Cargo.toml
+++ b/crates/trigger/Cargo.toml
@@ -11,7 +11,6 @@ clap = { version = "3.1.15", features = ["derive", "env"] }
 ctrlc = { version = "3.2", features = ["termination"] }
 dirs = "4"
 futures = "0.3"
-once_cell = "1.0"
 outbound-http = { path = "../outbound-http" }
 outbound-redis = { path = "../outbound-redis" }
 outbound-pg = { path = "../outbound-pg" }
diff --git a/crates/trigger/src/cli.rs b/crates/trigger/src/cli.rs
index a690b311..4b4221de 100644
--- a/crates/trigger/src/cli.rs
+++ b/crates/trigger/src/cli.rs
@@ -11,7 +11,10 @@ use tokio::{
 use spin_app::Loader;
 
 use crate::stdio::StdioLoggingTriggerHooks;
-use crate::{config::TriggerExecutorBuilderConfig, loader::TriggerLoader, stdio::FollowComponents};
+use crate::{
+    key_value::KeyValuePersistenceMessageHook, loader::TriggerLoader,
+    runtime_config::RuntimeConfig, stdio::FollowComponents,
+};
 use crate::{TriggerExecutor, TriggerExecutorBuilder};
 
 pub const APP_LOG_DIR: &str = "APP_LOG_DIR";
@@ -22,8 +25,8 @@ pub const RUNTIME_CONFIG_FILE: &str = "RUNTIME_CONFIG_FILE";
 
 // Set by `spin up`
 pub const SPIN_LOCKED_URL: &str = "SPIN_LOCKED_URL";
+pub const SPIN_LOCAL_APP_DIR: &str = "SPIN_LOCAL_APP_DIR";
 pub const SPIN_WORKING_DIR: &str = "SPIN_WORKING_DIR";
-pub const SPIN_STATE_DIR: &str = "SPIN_STATE_DIR";
 
 /// A command that runs a TriggerExecutor.
 #[derive(Parser, Debug)]
@@ -88,6 +91,15 @@ where
     )]
     pub runtime_config_file: Option<PathBuf>,
 
+    /// Set the application state directory path. This is used in the default
+    /// locations for logs, key value stores, etc.
+    ///
+    /// For local apps, this defaults to `.spin/` relative to the `spin.toml` file.
+    /// For remote apps, this has no default (unset).
+    /// Passing an empty value forces the value to be unset.
+    #[clap(long)]
+    pub state_dir: Option<String>,
+
     #[clap(flatten)]
     pub run_config: Executor::RunConfig,
 
@@ -147,29 +159,35 @@ where
         loader: impl Loader + Send + Sync + 'static,
         locked_url: String,
     ) -> Result<Executor> {
-        let trigger_config =
-            TriggerExecutorBuilderConfig::load_from_file(self.runtime_config_file.clone())?;
+        let runtime_config = self.build_runtime_config()?;
 
         let _sloth_warning = warn_if_wasm_build_slothful();
 
         let mut builder = TriggerExecutorBuilder::new(loader);
         self.update_wasmtime_config(builder.wasmtime_config_mut())?;
 
-        let logging_hooks = StdioLoggingTriggerHooks::new(self.follow_components(), self.log_dir());
-        builder.hooks(logging_hooks);
+        builder.hooks(StdioLoggingTriggerHooks::new(self.follow_components()));
+        builder.hooks(KeyValuePersistenceMessageHook);
 
-        builder.build(locked_url, trigger_config).await
+        builder.build(locked_url, runtime_config).await
     }
 
-    pub fn log_dir(&self) -> Option<PathBuf> {
-        self.log.clone().or_else(|| {
-            std::env::var(SPIN_STATE_DIR)
-                .ok()
-                .map(|dir| PathBuf::from(dir).join("logs"))
-        })
+    fn build_runtime_config(&self) -> Result<RuntimeConfig> {
+        let local_app_dir = std::env::var_os(SPIN_LOCAL_APP_DIR);
+        let mut config = RuntimeConfig::new(local_app_dir.map(Into::into));
+        if let Some(state_dir) = &self.state_dir {
+            config.set_state_dir(state_dir);
+        }
+        if let Some(log_dir) = &self.log {
+            config.set_log_dir(log_dir);
+        }
+        if let Some(config_file) = &self.runtime_config_file {
+            config.merge_config_from(config_file)?;
+        }
+        Ok(config)
     }
 
-    pub fn follow_components(&self) -> FollowComponents {
+    fn follow_components(&self) -> FollowComponents {
         if self.silence_component_logs {
             FollowComponents::None
         } else if self.follow_components.is_empty() {
diff --git a/crates/trigger/src/config.rs b/crates/trigger/src/config.rs
deleted file mode 100644
index 46818359..00000000
--- a/crates/trigger/src/config.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use std::path::PathBuf;
-
-use anyhow::Result;
-use serde::Deserialize;
-use toml;
-
-// Config for config providers and wasmtime config
-#[derive(Debug, Default, Deserialize)]
-pub struct TriggerExecutorBuilderConfig {
-    #[serde(rename = "config_provider", default)]
-    pub config_providers: Vec<ConfigProvider>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "snake_case", tag = "type")]
-pub enum ConfigProvider {
-    Vault(VaultConfig),
-}
-
-// Vault config to initialize vault provider
-#[derive(Debug, Default, Deserialize)]
-pub struct VaultConfig {
-    pub url: String,
-    pub token: String,
-    pub mount: String,
-    pub prefix: Option<String>,
-}
-
-impl TriggerExecutorBuilderConfig {
-    pub fn load_from_file(config_file: Option<PathBuf>) -> Result<Self> {
-        let config_file = match config_file {
-            Some(p) => p,
-            None => {
-                return Ok(Self::default());
-            }
-        };
-        let content = std::fs::read_to_string(config_file)?;
-        let config: TriggerExecutorBuilderConfig = toml::from_str(&content)?;
-        Ok(config)
-    }
-}
diff --git a/crates/trigger/src/key_value.rs b/crates/trigger/src/key_value.rs
new file mode 100644
index 00000000..5ad047b8
--- /dev/null
+++ b/crates/trigger/src/key_value.rs
@@ -0,0 +1,64 @@
+use std::{path::Path, sync::Arc};
+
+use anyhow::{Context, Result};
+use spin_key_value::{
+    component_key_value_stores, CachingStoreManager, DelegatingStoreManager, KeyValueComponent,
+};
+use spin_key_value_sqlite::{DatabaseLocation, KeyValueSqlite};
+
+use crate::{runtime_config::RuntimeConfig, TriggerHooks};
+
+// TODO: Once we have runtime configuration for key-value stores, the user will be able
+// to both change the default store configuration (e.g. use Redis, or an SQLite
+// in-memory database, or use a different path) and add other named stores with their
+// own configurations.
+
+pub(crate) fn build_key_value_component(
+    runtime_config: &RuntimeConfig,
+) -> Result<KeyValueComponent> {
+    let location = match runtime_config.sqlite_db_path() {
+        Some(path) => {
+            // Create the store's parent directory if necessary
+            create_parent_dir(&path).context("Failed to create key value store")?;
+            DatabaseLocation::Path(path)
+        }
+        None => DatabaseLocation::InMemory,
+    };
+
+    let manager = Arc::new(CachingStoreManager::new(DelegatingStoreManager::new([(
+        "default".to_owned(),
+        Arc::new(KeyValueSqlite::new(location)) as _,
+    )])));
+
+    Ok(KeyValueComponent::new(spin_key_value::manager(move |_| {
+        manager.clone()
+    })))
+}
+
+fn create_parent_dir(path: &Path) -> Result<()> {
+    let dir = path
+        .parent()
+        .with_context(|| format!("{path:?} missing parent dir"))?;
+    std::fs::create_dir_all(dir).with_context(|| format!("Failed to create parent dir {dir:?}"))
+}
+
+pub struct KeyValuePersistenceMessageHook;
+
+impl TriggerHooks for KeyValuePersistenceMessageHook {
+    fn app_loaded(&mut self, app: &spin_app::App, runtime_config: &RuntimeConfig) -> Result<()> {
+        // Don't print anything if the app doesn't use KV
+        if app.components().all(|c| {
+            component_key_value_stores(&c)
+                .unwrap_or_default()
+                .is_empty()
+        }) {
+            return Ok(());
+        }
+        if let Some(path) = runtime_config.sqlite_db_path() {
+            println!("Storing key-value data to {path:?}");
+        } else {
+            println!("Using in-memory key-value store; data will not be saved!");
+        }
+        Ok(())
+    }
+}
diff --git a/crates/trigger/src/lib.rs b/crates/trigger/src/lib.rs
index 3adf5317..de9fa645 100644
--- a/crates/trigger/src/lib.rs
+++ b/crates/trigger/src/lib.rs
@@ -1,34 +1,20 @@
 pub mod cli;
-pub mod config;
+mod key_value;
 pub mod loader;
 pub mod locked;
+pub mod runtime_config;
 mod stdio;
 
-use std::{
-    collections::HashMap,
-    fs,
-    marker::PhantomData,
-    path::{Path, PathBuf},
-    sync::Arc,
-};
+use std::{collections::HashMap, marker::PhantomData, path::PathBuf};
 
 use anyhow::{anyhow, Context, Result};
 pub use async_trait::async_trait;
-use once_cell::sync::OnceCell;
+use runtime_config::RuntimeConfig;
 use serde::de::DeserializeOwned;
-use url::Url;
 
 use spin_app::{App, AppComponent, AppLoader, AppTrigger, Loader, OwnedApp};
-use spin_config::{
-    provider::{env::EnvProvider, vault::VaultProvider},
-    Provider,
-};
 use spin_core::{Config, Engine, EngineBuilder, Instance, InstancePre, Store, StoreBuilder};
 
-const SPIN_CONFIG_ENV_PREFIX: &str = "SPIN_CONFIG";
-const DEFAULT_SQLITE_DB_DIRECTORY: &str = ".spin";
-const DEFAULT_SQLITE_DB_FILENAME: &str = "sqlite_key_value.db";
-
 #[async_trait]
 pub trait TriggerExecutor: Sized {
     const TRIGGER_TYPE: &'static str;
@@ -51,7 +37,7 @@ pub trait TriggerExecutor: Sized {
 pub struct TriggerExecutorBuilder<Executor: TriggerExecutor> {
     loader: AppLoader,
     config: Config,
-    hooks: Box<dyn TriggerHooks>,
+    hooks: Vec<Box<dyn TriggerHooks>>,
     disable_default_host_components: bool,
     _phantom: PhantomData<Executor>,
 }
@@ -62,7 +48,7 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
         Self {
             loader: AppLoader::new(loader),
             config: Default::default(),
-            hooks: Box::new(()),
+            hooks: Default::default(),
             disable_default_host_components: false,
             _phantom: PhantomData,
         }
@@ -76,7 +62,7 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
     }
 
     pub fn hooks(&mut self, hooks: impl TriggerHooks + 'static) -> &mut Self {
-        self.hooks = Box::new(hooks);
+        self.hooks.push(Box::new(hooks));
         self
     }
 
@@ -88,7 +74,7 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
     pub async fn build(
         mut self,
         app_uri: String,
-        builder_config: config::TriggerExecutorBuilderConfig,
+        runtime_config: runtime_config::RuntimeConfig,
     ) -> Result<Executor>
     where
         Executor::TriggerConfig: DeserializeOwned,
@@ -100,18 +86,9 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
                 builder.add_host_component(outbound_redis::OutboundRedisComponent)?;
                 builder.add_host_component(outbound_pg::OutboundPg::default())?;
                 builder.add_host_component(outbound_mysql::OutboundMysql::default())?;
-
-                let manager = OnceCell::new();
-
                 self.loader.add_dynamic_host_component(
                     &mut builder,
-                    spin_key_value::KeyValueComponent::new(spin_key_value::manager(
-                        move |component| {
-                            manager
-                                .get_or_init(|| make_store_manager(component))
-                                .clone()
-                        },
-                    )),
+                    crate::key_value::build_key_value_component(&runtime_config)?,
                 )?;
                 self.loader.add_dynamic_host_component(
                     &mut builder,
@@ -119,9 +96,7 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
                 )?;
                 self.loader.add_dynamic_host_component(
                     &mut builder,
-                    spin_config::ConfigHostComponent::new(
-                        self.get_config_providers(&app_uri, &builder_config),
-                    ),
+                    spin_config::ConfigHostComponent::new(runtime_config.config_providers()),
                 )?;
             }
 
@@ -133,46 +108,13 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
 
         let app_name = app.borrowed().require_metadata("name")?;
 
-        self.hooks.app_loaded(app.borrowed())?;
+        self.hooks
+            .iter_mut()
+            .try_for_each(|h| h.app_loaded(app.borrowed(), &runtime_config))?;
 
         // Run trigger executor
         Executor::new(TriggerAppEngine::new(engine, app_name, app, self.hooks).await?)
     }
-
-    pub fn get_config_providers(
-        &self,
-        app_uri: &str,
-        builder_config: &config::TriggerExecutorBuilderConfig,
-    ) -> Vec<Box<dyn Provider>> {
-        let mut providers = self.default_config_providers(app_uri);
-        for config_provider in &builder_config.config_providers {
-            let provider = match config_provider {
-                config::ConfigProvider::Vault(vault_config) => VaultProvider::new(
-                    &vault_config.url,
-                    &vault_config.token,
-                    &vault_config.mount,
-                    vault_config.prefix.clone(),
-                ),
-            };
-            providers.push(Box::new(provider));
-        }
-        providers
-    }
-
-    pub fn default_config_providers(&self, app_uri: &str) -> Vec<Box<dyn Provider>> {
-        // EnvProvider
-        // Look for a .env file in either the manifest parent directory for local apps
-        // or the current directory for remote (e.g. bindle) apps.
-        let dotenv_path = parse_file_url(app_uri)
-            .as_deref()
-            .ok()
-            .unwrap_or_else(|| Path::new("."))
-            .join(".env");
-        vec![Box::new(EnvProvider::new(
-            SPIN_CONFIG_ENV_PREFIX,
-            Some(dotenv_path),
-        ))]
-    }
 }
 
 /// Execution context for a TriggerExecutor executing a particular App.
@@ -184,7 +126,7 @@ pub struct TriggerAppEngine<Executor: TriggerExecutor> {
     // An owned wrapper of the App.
     app: OwnedApp,
     // Trigger hooks
-    hooks: Box<dyn TriggerHooks>,
+    hooks: Vec<Box<dyn TriggerHooks>>,
     // Trigger configs for this trigger type, with order matching `app.triggers_with_type(Executor::TRIGGER_TYPE)`
     trigger_configs: Vec<Executor::TriggerConfig>,
     // Map of {Component ID -> InstancePre} for each component.
@@ -198,7 +140,7 @@ impl<Executor: TriggerExecutor> TriggerAppEngine<Executor> {
         engine: Engine<Executor::RuntimeData>,
         app_name: String,
         app: OwnedApp,
-        hooks: Box<dyn TriggerHooks>,
+        hooks: Vec<Box<dyn TriggerHooks>>,
     ) -> Result<Self>
     where
         <Executor as TriggerExecutor>::TriggerConfig: DeserializeOwned,
@@ -250,7 +192,8 @@ impl<Executor: TriggerExecutor> TriggerAppEngine<Executor> {
         let mut builder = self.engine.store_builder();
         let component = self.get_component(component_id)?;
         self.hooks
-            .component_store_builder(component, &mut builder)?;
+            .iter()
+            .try_for_each(|h| h.component_store_builder(&component, &mut builder))?;
         Ok(builder)
     }
 
@@ -309,7 +252,7 @@ pub trait TriggerHooks: Send + Sync {
     #![allow(unused_variables)]
 
     /// Called once, immediately after an App is loaded.
-    fn app_loaded(&mut self, app: &App) -> Result<()> {
+    fn app_loaded(&mut self, app: &App, runtime_config: &RuntimeConfig) -> Result<()> {
         Ok(())
     }
 
@@ -318,7 +261,7 @@ pub trait TriggerHooks: Send + Sync {
     /// environment of the instance to be executed.
     fn component_store_builder(
         &self,
-        component: AppComponent,
+        component: &AppComponent,
         store_builder: &mut StoreBuilder,
     ) -> Result<()> {
         Ok(())
@@ -360,47 +303,3 @@ fn decode_preinstantiation_error(e: anyhow::Error) -> anyhow::Error {
 
     e
 }
-
-fn key_value_file_location(app: &App) -> Option<PathBuf> {
-    let url = Url::parse(&app.get_metadata::<String>("origin").ok()??).ok()?;
-    let local_spin_toml = if url.scheme() == "file" {
-        url.path()
-    } else {
-        // Use in-memory store for remote apps.
-        return None;
-    };
-
-    // Attempt to create or reuse a database file inside the app directory.  If that's not successful, we'll use an
-    // in-memory database.
-    let directory = Path::new(&local_spin_toml)
-        .parent()?
-        .join(DEFAULT_SQLITE_DB_DIRECTORY);
-
-    fs::create_dir_all(&directory).ok()?;
-
-    Some(directory.join(DEFAULT_SQLITE_DB_FILENAME))
-}
-
-fn make_store_manager(component: &AppComponent) -> Arc<dyn spin_key_value::StoreManager> {
-    // TODO: Once we have runtime configuration for key-value stores, the user will be able
-    // to both change the default store configuration (e.g. use Redis, or an SQLite
-    // in-memory database, or use a different path) and add other named stores with their
-    // own configurations.
-
-    Arc::new(spin_key_value::CachingStoreManager::new(
-        spin_key_value::DelegatingStoreManager::new(
-            [(
-                "default".to_owned(),
-                Arc::new(spin_key_value_sqlite::KeyValueSqlite::new(
-                    if let Some(key_value_file) = key_value_file_location(component.app) {
-                        spin_key_value_sqlite::DatabaseLocation::Path(key_value_file)
-                    } else {
-                        spin_key_value_sqlite::DatabaseLocation::InMemory
-                    },
-                )) as Arc<dyn spin_key_value::StoreManager>,
-            )]
-            .into_iter()
-            .collect(),
-        ),
-    ))
-}
diff --git a/crates/trigger/src/runtime_config.rs b/crates/trigger/src/runtime_config.rs
new file mode 100644
index 00000000..f0f606a6
--- /dev/null
+++ b/crates/trigger/src/runtime_config.rs
@@ -0,0 +1,265 @@
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+use spin_config::provider::{env::EnvProvider, vault::VaultProvider};
+use toml;
+
+pub const DEFAULT_STATE_DIR: &str = ".spin";
+const DEFAULT_LOGS_DIR: &str = "logs";
+
+const SPIN_CONFIG_ENV_PREFIX: &str = "SPIN_CONFIG";
+
+const DEFAULT_SQLITE_DB_FILENAME: &str = "sqlite_key_value.db";
+
+/// RuntimeConfig allows multiple sources of runtime configuration to be
+/// queried uniformly.
+#[derive(Debug, Default)]
+pub struct RuntimeConfig {
+    local_app_dir: Option<PathBuf>,
+    files: Vec<RuntimeConfigOpts>,
+    overrides: RuntimeConfigOpts,
+}
+
+impl RuntimeConfig {
+    // Gives more consistent conditional branches
+    #![allow(clippy::manual_map)]
+
+    pub fn new(local_app_dir: Option<PathBuf>) -> Self {
+        Self {
+            local_app_dir: local_app_dir.map(Into::into),
+            ..Default::default()
+        }
+    }
+
+    /// Load a runtime config file from the given path. Options specified in a
+    /// later-loaded file take precedence over any earlier-loaded files.
+    pub fn merge_config_from(&mut self, path: impl AsRef<Path>) -> Result<()> {
+        let path = path.as_ref();
+        let bytes = std::fs::read(path)
+            .with_context(|| format!("Failed to load runtime config file {path:?}"))?;
+        let opts = toml::from_slice(&bytes)
+            .with_context(|| format!("Failed to parse runtime config file {path:?}"))?;
+        self.files.push(opts);
+        Ok(())
+    }
+
+    /// Return an iterator of configured spin_config::Providers.
+    pub fn config_providers(&self) -> Vec<BoxedConfigProvider> {
+        // Default EnvProvider
+        let dotenv_path = self.local_app_dir.as_deref().map(|path| path.join(".env"));
+        let env_provider = EnvProvider::new(SPIN_CONFIG_ENV_PREFIX, dotenv_path);
+
+        let mut providers: Vec<BoxedConfigProvider> = vec![Box::new(env_provider)];
+        providers.extend(self.opts_layers().flat_map(|opts| {
+            opts.config_providers
+                .iter()
+                .map(Self::build_config_provider)
+        }));
+        providers
+    }
+
+    fn build_config_provider(provider_config: &ConfigProvider) -> BoxedConfigProvider {
+        match provider_config {
+            ConfigProvider::Vault(VaultConfig {
+                url,
+                token,
+                mount,
+                prefix,
+            }) => Box::new(VaultProvider::new(url, token, mount, prefix.as_deref())),
+        }
+    }
+
+    /// Set the state dir, overriding any other runtime config source.
+    pub fn set_state_dir(&mut self, state_dir: impl Into<String>) {
+        self.overrides.state_dir = Some(state_dir.into());
+    }
+
+    /// Return the state dir if set.
+    pub fn state_dir(&self) -> Option<PathBuf> {
+        if let Some(path_str) = self.find_opt(|opts| &opts.state_dir) {
+            if path_str.is_empty() {
+                None // An empty string forces the state dir to be unset
+            } else {
+                Some(path_str.into())
+            }
+        } else if let Some(app_dir) = &self.local_app_dir {
+            // If we're running a local app, return the default state dir
+            Some(app_dir.join(DEFAULT_STATE_DIR))
+        } else {
+            None
+        }
+    }
+
+    /// Set the log dir, overriding any other runtime config source.
+    pub fn set_log_dir(&mut self, log_dir: impl Into<PathBuf>) {
+        self.overrides.log_dir = Some(log_dir.into());
+    }
+
+    /// Return the log dir if set.
+    pub fn log_dir(&self) -> Option<PathBuf> {
+        if let Some(path) = self.find_opt(|opts| &opts.log_dir) {
+            // If there is an explicit log dir set, return it
+            Some(path.into())
+        } else if let Some(state_dir) = self.state_dir() {
+            // If the state dir is set, build the default path
+            Some(state_dir.join(DEFAULT_LOGS_DIR))
+        } else {
+            None
+        }
+    }
+
+    /// Return a path to the sqlite DB if set.
+    pub fn sqlite_db_path(&self) -> Option<PathBuf> {
+        if let Some(state_dir) = self.state_dir() {
+            // If the state dir is set, build the default path
+            Some(state_dir.join(DEFAULT_SQLITE_DB_FILENAME))
+        } else {
+            None
+        }
+    }
+
+    /// Returns an iterator of RuntimeConfigOpts in order of decreasing precedence
+    fn opts_layers(&self) -> impl Iterator<Item = &RuntimeConfigOpts> {
+        std::iter::once(&self.overrides).chain(self.files.iter().rev())
+    }
+
+    /// Returns the highest precedence RuntimeConfigOpts Option that is set
+    fn find_opt<T>(&self, mut f: impl FnMut(&RuntimeConfigOpts) -> &Option<T>) -> Option<&T> {
+        self.opts_layers().find_map(|opts| f(opts).as_ref())
+    }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RuntimeConfigOpts {
+    #[serde(default)]
+    pub state_dir: Option<String>,
+
+    #[serde(default)]
+    pub log_dir: Option<PathBuf>,
+
+    #[serde(rename = "config_provider", default)]
+    pub config_providers: Vec<ConfigProvider>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case", tag = "type")]
+pub enum ConfigProvider {
+    Vault(VaultConfig),
+}
+
+// Vault config to initialize vault provider
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct VaultConfig {
+    pub url: String,
+    pub token: String,
+    pub mount: String,
+    pub prefix: Option<String>,
+}
+
+type BoxedConfigProvider = Box<dyn spin_config::Provider>;
+
+#[cfg(test)]
+mod tests {
+    use std::io::Write;
+
+    use tempfile::NamedTempFile;
+    use toml::toml;
+
+    use super::*;
+
+    #[test]
+    fn defaults_without_local_app_dir() -> Result<()> {
+        let config = RuntimeConfig::new(None);
+
+        assert_eq!(config.state_dir(), None);
+        assert_eq!(config.log_dir(), None);
+        assert_eq!(config.sqlite_db_path(), None);
+
+        Ok(())
+    }
+
+    #[test]
+    fn defaults_with_local_app_dir() -> Result<()> {
+        let app_dir = tempfile::tempdir()?;
+        let config = RuntimeConfig::new(Some(app_dir.path().into()));
+
+        let state_dir = config.state_dir().unwrap();
+        assert!(state_dir.starts_with(&app_dir));
+
+        let log_dir = config.log_dir().unwrap();
+        assert!(log_dir.starts_with(&state_dir));
+
+        let sqlite_db_path = config.sqlite_db_path().unwrap();
+        assert!(sqlite_db_path.starts_with(&state_dir));
+
+        Ok(())
+    }
+
+    #[test]
+    fn state_dir_force_unset() -> Result<()> {
+        let app_dir = tempfile::tempdir()?;
+        let mut config = RuntimeConfig::new(Some(app_dir.path().into()));
+        assert!(config.state_dir().is_some());
+
+        config.set_state_dir("");
+        assert!(config.state_dir().is_none());
+
+        Ok(())
+    }
+
+    #[test]
+    fn opts_layers_precedence() -> Result<()> {
+        let mut config = RuntimeConfig::new(None);
+
+        config.merge_config_from(toml_tempfile(toml! {
+            state_dir = "file-state-dir"
+            log_dir = "file-log-dir"
+        })?)?;
+
+        let state_dir = config.state_dir().unwrap();
+        assert_eq!(state_dir.as_os_str(), "file-state-dir");
+
+        let log_dir = config.log_dir().unwrap();
+        assert_eq!(log_dir.as_os_str(), "file-log-dir");
+
+        config.set_state_dir("override-state-dir");
+        config.set_log_dir("override-log-dir");
+
+        let state_dir = config.state_dir().unwrap();
+        assert_eq!(state_dir.as_os_str(), "override-state-dir");
+
+        let log_dir = config.log_dir().unwrap();
+        assert_eq!(log_dir.as_os_str(), "override-log-dir");
+
+        Ok(())
+    }
+
+    #[test]
+    fn config_providers_from_file() -> Result<()> {
+        let mut config = RuntimeConfig::new(None);
+
+        // One default provider
+        assert_eq!(config.config_providers().len(), 1);
+
+        config.merge_config_from(toml_tempfile(toml! {
+            [[config_provider]]
+            type = "vault"
+            url = "http://vault"
+            token = "secret"
+            mount = "root"
+        })?)?;
+        assert_eq!(config.config_providers().len(), 2);
+
+        Ok(())
+    }
+
+    fn toml_tempfile(value: toml::Value) -> Result<NamedTempFile> {
+        let data = toml::to_vec(&value)?;
+        let mut file = NamedTempFile::new()?;
+        file.write_all(&data)?;
+        Ok(file)
+    }
+}
diff --git a/crates/trigger/src/stdio.rs b/crates/trigger/src/stdio.rs
index ca6c6d66..147fcc07 100644
--- a/crates/trigger/src/stdio.rs
+++ b/crates/trigger/src/stdio.rs
@@ -6,7 +6,7 @@ use std::{
 
 use anyhow::{Context, Result};
 
-use crate::TriggerHooks;
+use crate::{runtime_config::RuntimeConfig, TriggerHooks};
 
 /// Which components should have their logs followed on stdout/stderr.
 #[derive(Clone, Debug)]
@@ -43,10 +43,10 @@ pub struct StdioLoggingTriggerHooks {
 }
 
 impl StdioLoggingTriggerHooks {
-    pub fn new(follow_components: FollowComponents, log_dir: Option<PathBuf>) -> Self {
+    pub fn new(follow_components: FollowComponents) -> Self {
         Self {
             follow_components,
-            log_dir,
+            log_dir: None,
         }
     }
 
@@ -84,13 +84,21 @@ impl StdioLoggingTriggerHooks {
 }
 
 impl TriggerHooks for StdioLoggingTriggerHooks {
-    fn app_loaded(&mut self, app: &spin_app::App) -> anyhow::Result<()> {
+    fn app_loaded(
+        &mut self,
+        app: &spin_app::App,
+        runtime_config: &RuntimeConfig,
+    ) -> anyhow::Result<()> {
+        self.log_dir = runtime_config.log_dir();
+
         self.validate_follows(app)?;
 
-        // Ensure log dir exists if set
-        if let Some(l) = &self.log_dir {
-            std::fs::create_dir_all(l)
-                .with_context(|| format!("Failed to create log dir {l:?}"))?;
+        if let Some(dir) = &self.log_dir {
+            // Ensure log dir exists if set
+            std::fs::create_dir_all(dir)
+                .with_context(|| format!("Failed to create log dir {dir:?}"))?;
+
+            println!("Logging component stdio to {:?}", dir.join(""))
         }
 
         Ok(())
@@ -98,7 +106,7 @@ impl TriggerHooks for StdioLoggingTriggerHooks {
 
     fn component_store_builder(
         &self,
-        component: spin_app::AppComponent,
+        component: &spin_app::AppComponent,
         builder: &mut spin_core::StoreBuilder,
     ) -> anyhow::Result<()> {
         match &self.log_dir {
diff --git a/src/commands/up.rs b/src/commands/up.rs
index 400a8fc7..a88b442f 100644
--- a/src/commands/up.rs
+++ b/src/commands/up.rs
@@ -11,7 +11,7 @@ use spin_app::locked::LockedApp;
 use spin_loader::bindle::{deprecation::print_bindle_deprecation, BindleConnectionInfo};
 use spin_manifest::ApplicationTrigger;
 use spin_oci::OciLoader;
-use spin_trigger::cli::{SPIN_LOCKED_URL, SPIN_STATE_DIR, SPIN_WORKING_DIR};
+use spin_trigger::cli::{SPIN_LOCAL_APP_DIR, SPIN_LOCKED_URL, SPIN_WORKING_DIR};
 use tempfile::TempDir;
 
 use crate::opts::*;
@@ -186,12 +186,12 @@ impl UpCommand {
 
         self.update_locked_app(&mut locked_app);
 
-        let state_dir = self.prepare_state_dir(&app_source);
+        let local_app_dir = app_source.local_app_dir().map(Into::into);
 
         let run_opts = RunTriggerOpts {
             locked_app,
             working_dir,
-            state_dir,
+            local_app_dir,
         };
 
         self.run_trigger(trigger_cmd, Some(run_opts)).await
@@ -210,7 +210,7 @@ impl UpCommand {
         if let Some(RunTriggerOpts {
             locked_app,
             working_dir,
-            state_dir,
+            local_app_dir,
         }) = opts
         {
             let locked_url = self.write_locked_app(&locked_app, &working_dir).await?;
@@ -219,8 +219,8 @@ impl UpCommand {
                 .env(SPIN_WORKING_DIR, &working_dir)
                 .args(&self.trigger_args);
 
-            if let Some(state_dir) = state_dir {
-                cmd.env(SPIN_STATE_DIR, state_dir);
+            if let Some(local_app_dir) = local_app_dir {
+                cmd.env(SPIN_LOCAL_APP_DIR, local_app_dir);
             }
         } else {
             cmd.arg("--help-args-only");
@@ -395,13 +395,6 @@ impl UpCommand {
         spin_trigger::locked::build_locked_app(app, working_dir)
     }
 
-    fn prepare_state_dir(&self, source: &AppSource) -> Option<PathBuf> {
-        match source {
-            AppSource::File(path) => Some(path.parent().unwrap().join(".spin")),
-            _ => None,
-        }
-    }
-
     fn update_locked_app(&self, locked_app: &mut LockedApp) {
         // Apply --env to component environments
         if !self.env.is_empty() {
@@ -415,7 +408,7 @@ impl UpCommand {
 struct RunTriggerOpts {
     locked_app: LockedApp,
     working_dir: PathBuf,
-    state_dir: Option<PathBuf>,
+    local_app_dir: Option<PathBuf>,
 }
 
 enum WorkingDirectory {
@@ -510,6 +503,16 @@ impl AppSource {
     fn unresolvable(message: impl Into<String>) -> Self {
         Self::Unresolvable(message.into())
     }
+
+    fn local_app_dir(&self) -> Option<&Path> {
+        match self {
+            Self::File(path) => path.parent().or_else(|| {
+                tracing::warn!("Error finding local app dir from source {path:?}");
+                None
+            }),
+            _ => None,
+        }
+    }
 }
 
 #[cfg(test)]


Test patch:
diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs
index 7bf3f9c0..f2abf423 100644
--- a/crates/testing/src/lib.rs
+++ b/crates/testing/src/lib.rs
@@ -18,7 +18,7 @@ use spin_app::{
 };
 use spin_core::{Module, StoreBuilder};
 use spin_http::{HttpExecutorType, HttpTriggerConfig, WagiTriggerConfig};
-use spin_trigger::{config::TriggerExecutorBuilderConfig, TriggerExecutor, TriggerExecutorBuilder};
+use spin_trigger::{runtime_config::RuntimeConfig, TriggerExecutor, TriggerExecutorBuilder};
 
 pub use tokio;
 
@@ -100,10 +100,7 @@ impl HttpTestConfig {
         Executor::TriggerConfig: DeserializeOwned,
     {
         TriggerExecutorBuilder::new(self.build_loader())
-            .build(
-                TEST_APP_URI.to_string(),
-                TriggerExecutorBuilderConfig::default(),
-            )
+            .build(TEST_APP_URI.to_string(), RuntimeConfig::default())
             .await
             .unwrap()
     }
@@ -139,10 +136,7 @@ impl RedisTestConfig {
         self.redis_channel = channel.into();
 
         TriggerExecutorBuilder::new(self.build_loader())
-            .build(
-                TEST_APP_URI.to_string(),
-                TriggerExecutorBuilderConfig::default(),
-            )
+            .build(TEST_APP_URI.to_string(), RuntimeConfig::default())
             .await
             .unwrap()
     }