From 32998dfc159b10e5879e04a0e42a97bf4233dd40 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:05:56 +0530 Subject: [PATCH 01/19] feat: allow spin as a supported config store adapter --- crates/edgezero-core/src/manifest.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 571a496..8d6ea49 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -54,7 +54,7 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; +const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly", "spin"]; #[derive(Debug, Deserialize, Validate)] pub struct Manifest { @@ -1426,21 +1426,15 @@ name = "APP_CONFIG" } #[test] - fn config_store_spin_adapter_key_fails_validation() { + fn config_store_spin_adapter_key_passes_validation() { let src = r#" [stores.config.adapters.spin] name = "SPIN_CONFIG" "#; let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); - assert!( - result.is_err(), - "spin config store adapter key should fail validation because it is not implemented yet" - ); - let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("spin"), - "error should name the unknown adapter: {err_msg}" + manifest.validate().is_ok(), + "spin config store adapter key should pass validation" ); } From 1022cd7eac010297862b48e4b2da635144a2034b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:08:26 +0530 Subject: [PATCH 02/19] docs: update adapters field comment to include spin --- crates/edgezero-core/src/manifest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 8d6ea49..c6f7e27 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -404,7 +404,7 @@ pub struct ManifestConfigStoreConfig { #[validate(length(min = 1))] pub name: Option, /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, or `fastly`). + /// (`axum`, `cloudflare`, `fastly`, or `spin`). #[serde(default)] #[validate(nested)] #[validate(custom(function = "validate_config_store_adapter_keys"))] From 5bb805183fb222fcd7055bf197d9f02c45a260e3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:13:30 +0530 Subject: [PATCH 03/19] feat: add SpinConfigStore backed by spin_sdk::variables --- .../edgezero-adapter-spin/src/config_store.rs | 81 +++++++++++++++++++ crates/edgezero-adapter-spin/src/lib.rs | 4 + 2 files changed, 85 insertions(+) create mode 100644 crates/edgezero-adapter-spin/src/config_store.rs diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs new file mode 100644 index 0000000..c31a20b --- /dev/null +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -0,0 +1,81 @@ +//! Spin adapter config store: wraps `spin_sdk::variables`. + +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; + +/// Config store backed by Spin component variables. +pub struct SpinConfigStore { + inner: SpinConfigInner, +} + +enum SpinConfigInner { + #[cfg(target_arch = "wasm32")] + Spin, + #[cfg(test)] + InMemory(std::collections::HashMap), + /// Placeholder variant for non-wasm32, non-test builds. + /// + /// This variant is never constructed; it exists solely to keep the enum + /// inhabited so that `match` arms compile without `unreachable!()` noise. + #[cfg(not(any(target_arch = "wasm32", test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Create a new `SpinConfigStore` using the Spin variables API. + #[cfg(target_arch = "wasm32")] + pub fn new() -> Self { + Self { + inner: SpinConfigInner::Spin, + } + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { + inner: SpinConfigInner::InMemory(entries.into_iter().collect()), + } + } +} + +#[cfg(target_arch = "wasm32")] +impl Default for SpinConfigStore { + fn default() -> Self { + Self::new() + } +} + +impl ConfigStore for SpinConfigStore { + #[allow(unused_variables)] + fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(target_arch = "wasm32")] + SpinConfigInner::Spin => { + use spin_sdk::variables; + match variables::get(key) { + Ok(value) => Ok(Some(value)), + Err(variables::Error::Undefined(_)) => Ok(None), + Err(variables::Error::InvalidName(msg)) => { + Err(ConfigStoreError::invalid_key(msg)) + } + Err(e) => Err(ConfigStoreError::unavailable(e.to_string())), + } + } + #[cfg(test)] + SpinConfigInner::InMemory(data) => Ok(data.get(key).cloned()), + #[cfg(not(any(target_arch = "wasm32", test)))] + SpinConfigInner::_Uninhabited(never) => match *never {}, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + edgezero_core::config_store_contract_tests!(spin_config_store_contract, { + SpinConfigStore::from_entries([ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ]) + }); +} diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 9722fb5..0c86064 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -11,6 +11,8 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; +#[cfg(feature = "spin")] +pub mod config_store; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -19,6 +21,8 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; +#[cfg(feature = "spin")] +pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. /// From d64e95dc65c20d23669a33071ae6a881ad9a972d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:18:59 +0530 Subject: [PATCH 04/19] fix: rename SpinConfigBackend and remove feature gate on config_store module --- .../edgezero-adapter-spin/src/config_store.rs | 19 ++++++++----------- crates/edgezero-adapter-spin/src/lib.rs | 2 -- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index c31a20b..8547e65 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -4,18 +4,15 @@ use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; /// Config store backed by Spin component variables. pub struct SpinConfigStore { - inner: SpinConfigInner, + inner: SpinConfigBackend, } -enum SpinConfigInner { +enum SpinConfigBackend { #[cfg(target_arch = "wasm32")] Spin, #[cfg(test)] InMemory(std::collections::HashMap), - /// Placeholder variant for non-wasm32, non-test builds. - /// - /// This variant is never constructed; it exists solely to keep the enum - /// inhabited so that `match` arms compile without `unreachable!()` noise. + /// Never constructed; keeps the enum inhabited in non-wasm32, non-test builds. #[cfg(not(any(target_arch = "wasm32", test)))] _Uninhabited(std::convert::Infallible), } @@ -25,14 +22,14 @@ impl SpinConfigStore { #[cfg(target_arch = "wasm32")] pub fn new() -> Self { Self { - inner: SpinConfigInner::Spin, + inner: SpinConfigBackend::Spin, } } #[cfg(test)] fn from_entries(entries: impl IntoIterator) -> Self { Self { - inner: SpinConfigInner::InMemory(entries.into_iter().collect()), + inner: SpinConfigBackend::InMemory(entries.into_iter().collect()), } } } @@ -49,7 +46,7 @@ impl ConfigStore for SpinConfigStore { fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(target_arch = "wasm32")] - SpinConfigInner::Spin => { + SpinConfigBackend::Spin => { use spin_sdk::variables; match variables::get(key) { Ok(value) => Ok(Some(value)), @@ -61,9 +58,9 @@ impl ConfigStore for SpinConfigStore { } } #[cfg(test)] - SpinConfigInner::InMemory(data) => Ok(data.get(key).cloned()), + SpinConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), #[cfg(not(any(target_arch = "wasm32", test)))] - SpinConfigInner::_Uninhabited(never) => match *never {}, + SpinConfigBackend::_Uninhabited(never) => match *never {}, } } } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 0c86064..055fe47 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -11,7 +11,6 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; -#[cfg(feature = "spin")] pub mod config_store; pub use context::SpinRequestContext; @@ -21,7 +20,6 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; -#[cfg(feature = "spin")] pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. From 53dfa1c1c02fc4775688c77a3576e51de33b3650 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:22:10 +0530 Subject: [PATCH 05/19] fix: fmt ordering in lib.rs and add SpinConfigStore to ConfigStore trait docs --- crates/edgezero-adapter-spin/src/lib.rs | 4 ++-- crates/edgezero-core/src/config_store.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 055fe47..9b156e9 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -3,6 +3,7 @@ #[cfg(feature = "cli")] pub mod cli; +pub mod config_store; mod context; mod decompress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -11,8 +12,8 @@ mod proxy; mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; -pub mod config_store; +pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; @@ -20,7 +21,6 @@ pub use proxy::SpinProxyClient; pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; -pub use config_store::SpinConfigStore; /// Initialize the logger for Spin. /// diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 5112449..696dfc4 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -61,6 +61,7 @@ impl ConfigStoreError { /// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store /// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings +/// - `SpinConfigStore` (spin adapter) — Spin component variables pub trait ConfigStore: Send + Sync { /// Retrieve a config value by key. Returns `None` if the key does not exist. fn get(&self, key: &str) -> Result, ConfigStoreError>; From 917ff578072d6b75f022b699987978e5f5764239 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:24:08 +0530 Subject: [PATCH 06/19] feat: add SpinSecretStore backed by spin_sdk::variables --- crates/edgezero-adapter-spin/src/lib.rs | 4 ++ .../edgezero-adapter-spin/src/secret_store.rs | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 crates/edgezero-adapter-spin/src/secret_store.rs diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 9b156e9..69a89b4 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -16,6 +16,10 @@ mod response; pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod secret_store; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use secret_store::SpinSecretStore; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use request::{dispatch, into_core_request}; diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs new file mode 100644 index 0000000..2cbf691 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -0,0 +1,55 @@ +//! Spin adapter secret store: wraps `spin_sdk::variables`. +//! +//! Spin's variable namespace is flat — there is no concept of named stores. +//! The `store_name` parameter is intentionally ignored; provision secrets as +//! application variables in `spin.toml`. + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Spin component variables. +/// +/// `store_name` is ignored — Spin's variable namespace is flat. +/// Provision secrets as application variables in `spin.toml`. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub struct SpinSecretStore; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl SpinSecretStore { + pub fn new() -> Self { + Self + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl Default for SpinSecretStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SecretStore for SpinSecretStore { + async fn get_bytes( + &self, + _store_name: &str, + key: &str, + ) -> Result, SecretError> { + use spin_sdk::variables; + match variables::get(key) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(variables::Error::Undefined(_)) => Ok(None), + Err(e) => Err(SecretError::Internal(anyhow::anyhow!( + "secret lookup failed: {e}" + ))), + } + } +} + +// TODO: integration tests require the Spin runtime. +// Test SpinSecretStore as part of a Spin E2E test suite. From 8a4e2270f69b47c20e568a3f14759c284154fcf9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:35:08 +0530 Subject: [PATCH 07/19] feat: add SpinKvStore backed by spin_sdk::key_value --- .../src/key_value_store.rs | 140 ++++++++++++++++++ crates/edgezero-adapter-spin/src/lib.rs | 8 +- 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 crates/edgezero-adapter-spin/src/key_value_store.rs diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs new file mode 100644 index 0000000..19c4761 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -0,0 +1,140 @@ +//! Spin KV store adapter. +//! +//! Wraps `spin_sdk::key_value::Store` to implement the +//! `edgezero_core::key_value_store::KvStore` trait. +//! +//! # Limitations +//! +//! - **TTL**: The Spin KV API has no TTL support. Calls to +//! `put_bytes_with_ttl` store the value without expiry and emit a +//! `log::warn!`. +//! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys +//! with no prefix or cursor support. Prefix filtering and pagination are +//! performed in-process after fetching all keys. +//! +//! # Note +//! +//! This module is only compiled when the `spin` feature is enabled and the +//! target is `wasm32`. + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use std::time::Duration; + +/// KV store backed by the Spin KV API. +/// +/// Wraps a `spin_sdk::key_value::Store` handle obtained via +/// `Store::open(label)`. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub struct SpinKvStore { + store: spin_sdk::key_value::Store, +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +impl SpinKvStore { + /// Open a Spin KV store by label. + /// + /// The `label` must match a `key_value_stores` entry in `spin.toml`. + /// Returns `KvError::Internal` if the store cannot be opened. + pub fn open(label: &str) -> Result { + let store = spin_sdk::key_value::Store::open(label) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))?; + Ok(Self { store }) + } + + /// Open the default Spin KV store (label `"default"`). + pub fn open_default() -> Result { + Self::open("default") + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl KvStore for SpinKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.store + .get(key) + .map(|opt| opt.map(Bytes::from)) + .map_err(|e| KvError::Internal(anyhow::anyhow!("get failed: {e}"))) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store + .set(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + log::warn!( + "SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry" + ); + self.store + .set(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) + } + + async fn exists(&self, key: &str) -> Result { + self.store + .exists(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("exists failed: {e}"))) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let mut keys: Vec = self + .store + .get_keys() + .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))? + .into_iter() + .filter(|k| k.starts_with(prefix)) + .collect(); + + keys.sort(); + + // Advance past all keys <= last_key (the cursor). + let start = if let Some(last_key) = cursor { + keys.iter() + .position(|k| k.as_str() > last_key) + .unwrap_or(keys.len()) + } else { + 0 + }; + + let remaining = &keys[start..]; + let page_keys: Vec = remaining.iter().take(limit).cloned().collect(); + let has_more = remaining.len() > limit; + let next_cursor = if has_more { + page_keys.last().cloned() + } else { + None + }; + + Ok(KvPage { + keys: page_keys, + cursor: next_cursor, + }) + } +} + +// TODO: integration tests require the Spin runtime. +// Test `SpinKvStore` as part of a Spin E2E test suite. diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 69a89b4..1e50734 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -16,15 +16,19 @@ mod response; pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -mod secret_store; +mod key_value_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use secret_store::SpinSecretStore; +pub use key_value_store::SpinKvStore; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod secret_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use request::{dispatch, into_core_request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use response::from_core_response; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use secret_store::SpinSecretStore; /// Initialize the logger for Spin. /// From ac8facb255736217ea102aac1800b07d26cb03d4 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:39:10 +0530 Subject: [PATCH 08/19] chore: remove stale 'not yet implemented' note from run_app doc comment --- crates/edgezero-adapter-spin/src/lib.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 1e50734..6c3064f 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -78,11 +78,6 @@ impl AppExt for edgezero_core::app::App { /// edgezero_adapter_spin::run_app::(req).await /// } /// ``` -/// -/// **Note:** Config store, KV store, and secret store support are not yet -/// implemented for the Spin adapter. The `[stores.config]`, `[stores.kv]`, -/// and `[stores.secrets]` manifest sections are intentionally rejected for -/// the `spin` adapter. See the manifest validation error for details. #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub async fn run_app( req: spin_sdk::http::IncomingRequest, From a0ec8e64ed1b4b0c69b8f3b0ada249e8413011bf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:49:32 +0530 Subject: [PATCH 09/19] ci: add spin-adapter-tests job --- .github/workflows/test.yml | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c87283..015a0f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -172,3 +172,44 @@ jobs: - name: Check Fastly wasm target run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 + + spin-adapter-tests: + name: spin adapter tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-spin-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-spin- + + - name: Retrieve Rust version + id: rust-version-spin + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-spin.outputs.rust-version }} + + - name: Add wasm32-wasip1 target + run: rustup target add wasm32-wasip1 + + - name: Fetch dependencies (locked) + run: cargo fetch --locked + + - name: Run Spin adapter native tests + run: cargo test -p edgezero-adapter-spin --features spin + + - name: Check Spin wasm32 compilation + run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin From cf57f583c4bffb6efd81c59d10beb16cce733419 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 20:54:46 +0530 Subject: [PATCH 10/19] fix: handle InvalidName in SpinSecretStore and restore allow(unused_variables) --- crates/edgezero-adapter-spin/src/config_store.rs | 1 + crates/edgezero-adapter-spin/src/secret_store.rs | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 8547e65..bfa8d49 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -42,6 +42,7 @@ impl Default for SpinConfigStore { } impl ConfigStore for SpinConfigStore { + // `key` is unused in the _Uninhabited arm on native non-test builds #[allow(unused_variables)] fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index 2cbf691..8d971af 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -35,15 +35,12 @@ impl Default for SpinSecretStore { #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for SpinSecretStore { - async fn get_bytes( - &self, - _store_name: &str, - key: &str, - ) -> Result, SecretError> { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; match variables::get(key) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), Err(variables::Error::Undefined(_)) => Ok(None), + Err(variables::Error::InvalidName(msg)) => Err(SecretError::Validation(msg)), Err(e) => Err(SecretError::Internal(anyhow::anyhow!( "secret lookup failed: {e}" ))), From ec7dc149f0d990519b37797b4ce4f12475430679 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 21:01:23 +0530 Subject: [PATCH 11/19] feat: add configurable max_list_keys cap to SpinKvStore --- .../src/key_value_store.rs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index 19c4761..924a7e3 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -10,7 +10,12 @@ //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys //! with no prefix or cursor support. Prefix filtering and pagination are -//! performed in-process after fetching all keys. +//! performed in-process after fetching all keys. A configurable cap +//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) guards against +//! unbounded allocations; when the total key count exceeds it the list is +//! silently truncated, a `log::warn!` is emitted, and a partial page is +//! returned so the caller can resume via cursor (matching the Axum adapter +//! behaviour for its scan-batch limit). //! //! # Note //! @@ -26,6 +31,12 @@ use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; +/// Maximum number of keys fetched from the Spin KV host before +/// `list_keys_page` returns `KvError::Validation`. Overridable via +/// [`SpinKvStore::with_max_list_keys`]. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub const DEFAULT_MAX_LIST_KEYS: usize = 10_000; + /// KV store backed by the Spin KV API. /// /// Wraps a `spin_sdk::key_value::Store` handle obtained via @@ -33,6 +44,7 @@ use std::time::Duration; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinKvStore { store: spin_sdk::key_value::Store, + max_list_keys: usize, } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -44,13 +56,26 @@ impl SpinKvStore { pub fn open(label: &str) -> Result { let store = spin_sdk::key_value::Store::open(label) .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))?; - Ok(Self { store }) + Ok(Self { + store, + max_list_keys: DEFAULT_MAX_LIST_KEYS, + }) } /// Open the default Spin KV store (label `"default"`). pub fn open_default() -> Result { Self::open("default") } + + /// Override the maximum number of keys fetched during `list_keys_page`. + /// + /// When the Spin KV store contains more than `limit` keys, + /// `list_keys_page` returns `KvError::Validation` rather than + /// allocating an unbounded `Vec`. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. + pub fn with_max_list_keys(mut self, limit: usize) -> Self { + self.max_list_keys = limit; + self + } } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -75,9 +100,7 @@ impl KvStore for SpinKvStore { value: Bytes, _ttl: Duration, ) -> Result<(), KvError> { - log::warn!( - "SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry" - ); + log::warn!("SpinKvStore: TTL is not supported by the Spin KV API; storing without expiry"); self.store .set(key, value.as_ref()) .map_err(|e| KvError::Internal(anyhow::anyhow!("set failed: {e}"))) @@ -101,11 +124,24 @@ impl KvStore for SpinKvStore { cursor: Option<&str>, limit: usize, ) -> Result { - let mut keys: Vec = self + let all_keys = self .store .get_keys() - .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))? + .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; + + if all_keys.len() > self.max_list_keys { + log::warn!( + "SpinKvStore: fetched {} keys, exceeding max_list_keys={}; \ + processing the first {} keys only. Use with_max_list_keys to scan the full store.", + all_keys.len(), + self.max_list_keys, + self.max_list_keys, + ); + } + + let mut keys: Vec = all_keys .into_iter() + .take(self.max_list_keys) .filter(|k| k.starts_with(prefix)) .collect(); From a905d5af3aaa46baed75ffc06990a9a4385a28cc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:14:58 +0530 Subject: [PATCH 12/19] feat: wire store handles into Spin dispatch and add contract tests - dispatch() in request.rs now injects ConfigStoreHandle, KvHandle, and SecretHandle into request extensions on every request - SpinSecretStore normalises the lookup key to lowercase so conventional uppercase names (e.g. SMOKE_SECRET) resolve to the correct Spin variable - contract.rs gains store injection smoke tests (config, kv, secret) and wasm32 compile-time trait checks for SpinKvStore and SpinSecretStore --- crates/edgezero-adapter-spin/src/request.rs | 39 +++- .../edgezero-adapter-spin/src/secret_store.rs | 6 +- .../edgezero-adapter-spin/tests/contract.rs | 202 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 736bb2c..c8a305f 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -1,11 +1,19 @@ +use std::sync::Arc; + +use crate::config_store::SpinConfigStore; use crate::context::SpinRequestContext; +use crate::key_value_store::SpinKvStore; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; +use crate::secret_store::SpinSecretStore; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request, Uri}; +use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use spin_sdk::http::IncomingRequest; /// Convert a Spin `IncomingRequest` into an EdgeZero core `Request`. @@ -84,8 +92,37 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { - let core_request = into_core_request(req).await?; + let mut core_request = into_core_request(req).await?; + + core_request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(SpinConfigStore::new()))); + + match SpinKvStore::open_default() { + Ok(store) => { + core_request + .extensions_mut() + .insert(KvHandle::new(Arc::new(store))); + } + Err(e) => { + log::warn!( + "SpinKvStore: could not open default KV store (label \"default\"); \ + KV operations will be unavailable: {e}" + ); + } + } + + core_request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(SpinSecretStore::new()))); + let response = app.router().oneshot(core_request).await; Ok(from_core_response(response).await?) } diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index 8d971af..b252b59 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -37,7 +37,11 @@ impl Default for SpinSecretStore { impl SecretStore for SpinSecretStore { async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; - match variables::get(key) { + // Spin variable names are always lowercase. Normalise the key so that + // conventional uppercase secret names (e.g. "STRIPE_KEY") work without + // callers needing to know the Spin naming convention. + let lower = key.to_ascii_lowercase(); + match variables::get(&lower) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), Err(variables::Error::Undefined(_)) => Ok(None), Err(variables::Error::InvalidName(msg)) => Err(SecretError::Validation(msg)), diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 2df70de..fb27673 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -2,12 +2,70 @@ use bytes::Bytes; use edgezero_adapter_spin::SpinRequestContext; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, response_builder, Response, StatusCode}; +use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; use edgezero_core::router::RouterService; +use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; use futures::executor::block_on; use futures::stream; +use std::sync::Arc; + +struct FixedConfigStore(&'static str); + +impl ConfigStore for FixedConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_string())) + } +} + +struct StubKvStore; + +#[async_trait::async_trait(?Send)] +impl KvStore for StubKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + Ok(()) + } + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + async fn exists(&self, _key: &str) -> Result { + Ok(false) + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: vec![], + cursor: None, + }) + } +} + +struct StubSecretStore; + +#[async_trait::async_trait(?Send)] +impl SecretStore for StubSecretStore { + async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { + Ok(None) + } +} fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { @@ -41,10 +99,51 @@ fn build_test_app() -> App { Ok(response) } + async fn config_value(ctx: RequestContext) -> Result { + let value = ctx + .config_store() + .and_then(|store| store.get("greeting").ok().flatten()) + .unwrap_or_else(|| "missing".to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + + async fn kv_presence(ctx: RequestContext) -> Result { + let body = Body::text(if ctx.kv_handle().is_some() { + "yes" + } else { + "no" + }); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + + async fn secret_presence(ctx: RequestContext) -> Result { + let body = Body::text(if ctx.secret_handle().is_some() { + "yes" + } else { + "no" + }); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + let router = RouterService::builder() .get("/uri", capture_uri) .post("/mirror", mirror_body) .get("/stream", stream_response) + .get("/config", config_value) + .get("/has-kv", kv_presence) + .get("/has-secret", secret_presence) .build(); App::new(router) @@ -127,6 +226,95 @@ fn router_dispatches_streaming_route() { assert_eq!(collected, b"chunk-1chunk-2"); } +// --------------------------------------------------------------------------- +// Store injection smoke tests (host-side, no Spin runtime required) +// --------------------------------------------------------------------------- + +#[test] +fn config_store_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/config") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore( + "hello-spin", + )))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"hello-spin"); +} + +#[test] +fn kv_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/has-kv") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(KvHandle::new(Arc::new(StubKvStore))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"yes"); +} + +#[test] +fn secret_handle_is_accessible_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/has-secret") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(StubSecretStore))); + + let response = block_on(app.router().oneshot(request)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"yes"); +} + +#[test] +fn missing_store_handles_return_absent_values_in_handler() { + let app = build_test_app(); + + let config_req = request_builder() + .method("GET") + .uri("http://example.com/config") + .body(Body::empty()) + .expect("request"); + let config_response = block_on(app.router().oneshot(config_req)); + assert_eq!(config_response.body().as_bytes(), b"missing"); + + let kv_req = request_builder() + .method("GET") + .uri("http://example.com/has-kv") + .body(Body::empty()) + .expect("request"); + let kv_response = block_on(app.router().oneshot(kv_req)); + assert_eq!(kv_response.body().as_bytes(), b"no"); + + let secret_req = request_builder() + .method("GET") + .uri("http://example.com/has-secret") + .body(Body::empty()) + .expect("request"); + let secret_response = block_on(app.router().oneshot(secret_req)); + assert_eq!(secret_response.body().as_bytes(), b"no"); +} + // --------------------------------------------------------------------------- // Tests that require `spin_sdk` types (wasm32 + spin feature only) // @@ -192,3 +380,17 @@ mod wasm { }); } } + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod store_trait_compile_checks { + use edgezero_adapter_spin::{SpinKvStore, SpinSecretStore}; + use edgezero_core::key_value_store::KvStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_kv_impl() {} + fn _assert_secret_impl() {} + fn _check() { + _assert_kv_impl::(); + _assert_secret_impl::(); + } +} From 11aa4626d113392b96e88732aca1ef61dad13468 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:15:17 +0530 Subject: [PATCH 13/19] feat: add Spin adapter support to smoke test scripts - spin.toml gains key_value_stores = ["default"] binding and variables declarations for greeting and smoke_secret - edgezero.toml adds "spin" to adapters for config, kv, and secrets routes - smoke_test_kv/config/secrets.sh each gain a spin case that builds the WASM binary and starts spin up --listen 127.0.0.1:3000; the config script skips dotted-key checks (Spin variable names cannot contain dots); the secrets script passes SPIN_VARIABLE_SMOKE_SECRET at startup --- .../crates/app-demo-adapter-spin/spin.toml | 16 +++++++++ examples/app-demo/edgezero.toml | 12 +++---- scripts/smoke_test_config.sh | 34 +++++++++++++++---- scripts/smoke_test_kv.sh | 15 +++++++- scripts/smoke_test_secrets.sh | 24 ++++++++++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml index 3133f8c..d5c7595 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml @@ -4,6 +4,15 @@ spin_manifest_version = 2 name = "app-demo-adapter-spin" version = "0.1.0" +# Application-level variable declarations. +# Spin variable names are lowercase; set overrides at runtime via +# SPIN_VARIABLE_=value or `spin up --env KEY=value`. +[variables] +greeting = { default = "hello from config store" } +# smoke_secret has an empty default so the server starts without a value set. +# Pass SPIN_VARIABLE_SMOKE_SECRET= when running smoke_test_secrets.sh. +smoke_secret = { default = "" } + # Component name is shortened for brevity; scaffolded projects use the full # adapter crate name (e.g. "{{proj_spin}}") via the template. [[trigger.http]] @@ -13,6 +22,13 @@ component = "app-demo" [component.app-demo] source = "../../target/wasm32-wasip1/release/app_demo_adapter_spin.wasm" allowed_outbound_hosts = ["https://*:*"] +# KV store bound to the "default" label; SpinKvStore opens this label by default. +key_value_stores = ["default"] + +[component.app-demo.variables] +greeting = "{{ greeting }}" +smoke_secret = "{{ smoke_secret }}" + [component.app-demo.build] command = "cargo build --target wasm32-wasip1 --release" watch = ["src/**/*.rs", "Cargo.toml"] diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 497df6d..c6be1f4 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -57,7 +57,7 @@ id = "config_get" path = "/config/{name}" methods = ["GET"] handler = "app_demo_core::handlers::config_get" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] # -- KV demo routes -------------------------------------------------------- @@ -66,7 +66,7 @@ id = "kv_counter" path = "/kv/counter" methods = ["POST"] handler = "app_demo_core::handlers::kv_counter" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Increment and return a visit counter stored in KV" [[triggers.http]] @@ -74,7 +74,7 @@ id = "kv_note_put" path = "/kv/notes/{id}" methods = ["POST"] handler = "app_demo_core::handlers::kv_note_put" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Store a note by id" [[triggers.http]] @@ -82,7 +82,7 @@ id = "kv_note_get" path = "/kv/notes/{id}" methods = ["GET"] handler = "app_demo_core::handlers::kv_note_get" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Read a note by id" [[triggers.http]] @@ -90,7 +90,7 @@ id = "kv_note_delete" path = "/kv/notes/{id}" methods = ["DELETE"] handler = "app_demo_core::handlers::kv_note_delete" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Delete a note by id" # -- Secrets demo route -------------------------------------------------------- @@ -100,7 +100,7 @@ id = "secrets_echo" path = "/secrets/echo" methods = ["GET"] handler = "app_demo_core::handlers::secrets_echo" -adapters = ["axum", "cloudflare", "fastly"] +adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Echo an allowlisted smoke-test secret value (smoke-test only — do not use in production)" # -- Stores ---------------------------------------------------------------- diff --git a/scripts/smoke_test_config.sh b/scripts/smoke_test_config.sh index 5de4c1a..9e67c4d 100755 --- a/scripts/smoke_test_config.sh +++ b/scripts/smoke_test_config.sh @@ -9,6 +9,10 @@ set -euo pipefail # ./scripts/smoke_test_config.sh axum # ./scripts/smoke_test_config.sh fastly # ./scripts/smoke_test_config.sh cloudflare +# ./scripts/smoke_test_config.sh spin +# +# Note (spin): Spin variable names may not contain dots. Keys with dots +# (feature.new_checkout, service.timeout_ms) are skipped for the spin adapter. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -57,9 +61,21 @@ case "$ADAPTER" in (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac @@ -115,14 +131,18 @@ check "GET /config/greeting returns 200" "200" "$STATUS" BODY=$(curl -s "$BASE/config/greeting") check "greeting value" "hello from config store" "$BODY" -STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/feature.new_checkout") -check "GET /config/feature.new_checkout returns 200" "200" "$STATUS" +# Spin variable names cannot contain dots; these keys are only tested on +# adapters whose config stores support arbitrary key names. +if [ "$ADAPTER" != "spin" ]; then + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/feature.new_checkout") + check "GET /config/feature.new_checkout returns 200" "200" "$STATUS" -BODY=$(curl -s "$BASE/config/feature.new_checkout") -check "feature.new_checkout value" "false" "$BODY" + BODY=$(curl -s "$BASE/config/feature.new_checkout") + check "feature.new_checkout value" "false" "$BODY" -BODY=$(curl -s "$BASE/config/service.timeout_ms") -check "service.timeout_ms value" "1500" "$BODY" + BODY=$(curl -s "$BASE/config/service.timeout_ms") + check "service.timeout_ms value" "1500" "$BODY" +fi section "Config: missing key returns 404" STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/does.not.exist") diff --git a/scripts/smoke_test_kv.sh b/scripts/smoke_test_kv.sh index f2d0c1a..b11c1da 100755 --- a/scripts/smoke_test_kv.sh +++ b/scripts/smoke_test_kv.sh @@ -9,6 +9,7 @@ set -euo pipefail # ./scripts/smoke_test_kv.sh axum # ./scripts/smoke_test_kv.sh fastly # ./scripts/smoke_test_kv.sh cloudflare +# ./scripts/smoke_test_kv.sh spin ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -58,9 +59,21 @@ case "$ADAPTER" in (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac diff --git a/scripts/smoke_test_secrets.sh b/scripts/smoke_test_secrets.sh index 764c1a3..2ad8998 100755 --- a/scripts/smoke_test_secrets.sh +++ b/scripts/smoke_test_secrets.sh @@ -9,6 +9,12 @@ set -euo pipefail # ./scripts/smoke_test_secrets.sh axum # ./scripts/smoke_test_secrets.sh fastly # ./scripts/smoke_test_secrets.sh cloudflare +# ./scripts/smoke_test_secrets.sh spin +# +# Note (spin): Spin variable names are lowercase. SpinSecretStore normalises +# the key to lowercase before lookup, so "SMOKE_SECRET" maps to the Spin +# variable "smoke_secret". The secret value is passed at startup via +# SPIN_VARIABLE_SMOKE_SECRET. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEMO_DIR="$ROOT_DIR/examples/app-demo" @@ -108,9 +114,25 @@ start_server() { (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & SERVER_PID=$! ;; + spin) + PORT=3000 + command -v spin >/dev/null 2>&1 || { + echo "Spin CLI is required. Install from https://developer.fermyon.com/spin/v3/install" >&2 + exit 1 + } + echo "==> Building Spin WASM (wasm32-wasip1)..." + (cd "$DEMO_DIR" && cargo build --target wasm32-wasip1 --release -p app-demo-adapter-spin 2>&1) + echo "==> Starting Spin on port $PORT..." + # SpinSecretStore normalises the key to lowercase, so SMOKE_SECRET maps to + # the Spin variable smoke_secret. Pass the value via SPIN_VARIABLE_SMOKE_SECRET. + (cd "$DEMO_DIR/crates/app-demo-adapter-spin" && \ + SPIN_VARIABLE_SMOKE_SECRET="$SMOKE_SECRET_VALUE" \ + spin up --listen "127.0.0.1:$PORT" 2>&1) & + SERVER_PID=$! + ;; *) echo "Unknown adapter: $ADAPTER" >&2 - echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + echo "Usage: $0 [axum|fastly|cloudflare|spin]" >&2 exit 1 ;; esac From 58c7b6ee121aa72fb424da8c5d4e587b288c07d5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 24 Apr 2026 22:15:29 +0530 Subject: [PATCH 14/19] chore: ignore .spin/ runtime directory spin up creates .spin/ (SQLite KV database and component logs) in the adapter directory during local development, mirroring .wrangler/ for CF. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 48a5ede..0389097 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ bin/ pkg/ target/ .wrangler/ +.spin/ .edgezero/ # env From b5129da04ce5cecca86f445495022739bddf0f0d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 25 Apr 2026 18:12:37 +0530 Subject: [PATCH 15/19] - Return KvError::Validation when key count exceeds max_list_keys instead of silently truncating; callers now get an explicit signal rather than incomplete pagination results - Correct DEFAULT_MAX_LIST_KEYS, with_max_list_keys, and module-level docs to accurately describe error-return behaviour (not truncation, not "unbounded allocation" guarding) - Add log::debug in SpinSecretStore::get_bytes when store_name is non-empty so callers learn the flat-namespace constraint at runtime - Add comment in config_store contract tests explaining the InMemory backend accepts dotted/uppercase keys that the real Spin backend would reject via InvalidName - Add comment in lib.rs explaining why SpinConfigStore has different feature gating than SpinKvStore and SpinSecretStore --- .../edgezero-adapter-spin/src/config_store.rs | 5 +++ .../src/key_value_store.rs | 33 +++++++++---------- crates/edgezero-adapter-spin/src/lib.rs | 5 +++ .../edgezero-adapter-spin/src/secret_store.rs | 10 +++++- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index bfa8d49..84089f5 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -70,6 +70,11 @@ impl ConfigStore for SpinConfigStore { mod tests { use super::*; + // These contract tests exercise the InMemory backend (not the real Spin + // variables API). Dotted keys such as "contract.key.a" are valid here but + // would trigger `InvalidName` on the real Spin backend, which requires + // lowercase variable names without dots. Real-backend behaviour is + // verified by the smoke tests in scripts/smoke_test_config.sh. edgezero_core::config_store_contract_tests!(spin_config_store_contract, { SpinConfigStore::from_entries([ ("contract.key.a".to_string(), "value_a".to_string()), diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index 924a7e3..d5d1940 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -9,13 +9,14 @@ //! `put_bytes_with_ttl` store the value without expiry and emit a //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys -//! with no prefix or cursor support. Prefix filtering and pagination are -//! performed in-process after fetching all keys. A configurable cap -//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) guards against -//! unbounded allocations; when the total key count exceeds it the list is -//! silently truncated, a `log::warn!` is emitted, and a partial page is -//! returned so the caller can resume via cursor (matching the Axum adapter -//! behaviour for its scan-batch limit). +//! with no prefix or cursor support. All keys are fetched from the host, +//! then prefix filtering and pagination are performed in-process. A +//! configurable cap (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) +//! limits how many keys may be processed; when the store contains more keys +//! than the cap, `list_keys_page` returns `KvError::Validation` so the +//! caller can detect the condition and raise the cap via +//! [`SpinKvStore::with_max_list_keys`] rather than silently receiving +//! incomplete results. //! //! # Note //! @@ -31,7 +32,7 @@ use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; -/// Maximum number of keys fetched from the Spin KV host before +/// Maximum number of keys the Spin KV host may return before /// `list_keys_page` returns `KvError::Validation`. Overridable via /// [`SpinKvStore::with_max_list_keys`]. #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -67,11 +68,11 @@ impl SpinKvStore { Self::open("default") } - /// Override the maximum number of keys fetched during `list_keys_page`. + /// Override the maximum number of keys allowed during `list_keys_page`. /// /// When the Spin KV store contains more than `limit` keys, - /// `list_keys_page` returns `KvError::Validation` rather than - /// allocating an unbounded `Vec`. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. + /// `list_keys_page` returns `KvError::Validation` instead of returning + /// incomplete results. Defaults to [`DEFAULT_MAX_LIST_KEYS`]. pub fn with_max_list_keys(mut self, limit: usize) -> Self { self.max_list_keys = limit; self @@ -130,18 +131,16 @@ impl KvStore for SpinKvStore { .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; if all_keys.len() > self.max_list_keys { - log::warn!( - "SpinKvStore: fetched {} keys, exceeding max_list_keys={}; \ - processing the first {} keys only. Use with_max_list_keys to scan the full store.", + return Err(KvError::Validation(format!( + "SpinKvStore: store contains {} keys, exceeding max_list_keys={}; \ + call with_max_list_keys to raise the cap", all_keys.len(), self.max_list_keys, - self.max_list_keys, - ); + ))); } let mut keys: Vec = all_keys .into_iter() - .take(self.max_list_keys) .filter(|k| k.starts_with(prefix)) .collect(); diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 6c3064f..b28d080 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -13,6 +13,11 @@ mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod response; +// SpinConfigStore is available without the `spin` feature flag because it +// gates its spin_sdk usage on `cfg(target_arch = "wasm32")` internally, +// allowing the InMemory test backend to compile on all targets. SpinKvStore +// and SpinSecretStore import spin_sdk types at the module level and therefore +// require `all(feature = "spin", target_arch = "wasm32")`. pub use config_store::SpinConfigStore; pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index b252b59..d26924d 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -35,8 +35,16 @@ impl Default for SpinSecretStore { #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for SpinSecretStore { - async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { use spin_sdk::variables; + if !store_name.is_empty() { + // Spin's variable namespace is flat; named stores are not supported. + log::debug!( + "SpinSecretStore: store_name {:?} is ignored; \ + Spin uses a single flat variable namespace", + store_name + ); + } // Spin variable names are always lowercase. Normalise the key so that // conventional uppercase secret names (e.g. "STRIPE_KEY") work without // callers needing to know the Spin naming convention. From c001103cf03ed43ceaae847469d178a482c046b6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 25 Apr 2026 18:16:21 +0530 Subject: [PATCH 16/19] fix CI cache conflict --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 015a0f2..dfe5da6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,7 +114,11 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install wasm-bindgen test runner - run: cargo install wasm-bindgen-cli --version "${{ steps.wasm-bindgen-version.outputs.version }}" --locked + run: | + target="${{ steps.wasm-bindgen-version.outputs.version }}" + if ! wasm-bindgen --version 2>/dev/null | grep -qF "$target"; then + cargo install wasm-bindgen-cli --version "$target" --locked + fi - name: Fetch dependencies (locked) run: cargo fetch --locked From 3b995e53ccb0a4bed4e3aa99dccdf58340f59789 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 14:40:18 +0530 Subject: [PATCH 17/19] Fix test.yml to use matrix run --- .github/workflows/test.yml | 159 ++++++------------ .../edgezero-adapter-spin/src/config_store.rs | 8 +- .../src/key_value_store.rs | 23 +-- crates/edgezero-adapter-spin/src/lib.rs | 9 +- crates/edgezero-adapter-spin/src/request.rs | 19 ++- .../edgezero-adapter-spin/src/secret_store.rs | 10 +- .../edgezero-adapter-spin/tests/contract.rs | 141 ++++++++++------ 7 files changed, 187 insertions(+), 182 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfe5da6..6bc6c89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,12 +46,6 @@ jobs: - name: Add wasm targets run: rustup target add wasm32-wasip1 wasm32-unknown-unknown - - name: Setup Viceroy - run: | - if ! command -v viceroy &>/dev/null; then - cargo install viceroy --locked - fi - - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -61,12 +55,25 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" - - name: Check Spin wasm32 compilation - run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin - - cloudflare-wasm-tests: - name: cloudflare wasm tests + adapter-wasm-tests: + name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - adapter: cloudflare + target: wasm32-unknown-unknown + runner_env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER + runner_value: wasm-bindgen-test-runner + - adapter: fastly + target: wasm32-wasip1 + runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + runner_value: viceroy run + - adapter: spin + target: wasm32-wasip1 + runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + runner_value: wasmtime run steps: - uses: actions/checkout@v4 @@ -79,24 +86,25 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-cloudflare-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-cloudflare- + ${{ runner.os }}-cargo-${{ matrix.adapter }}- - name: Retrieve Rust version - id: rust-version-cloudflare + id: rust-version run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT shell: bash - name: Set up Rust tool chain uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: ${{ steps.rust-version-cloudflare.outputs.rust-version }} + toolchain: ${{ steps.rust-version.outputs.rust-version }} - - name: Add wasm32 target - run: rustup target add wasm32-unknown-unknown + - name: Add wasm target + run: rustup target add ${{ matrix.target }} - name: Resolve wasm-bindgen CLI version + if: matrix.adapter == 'cloudflare' id: wasm-bindgen-version shell: bash run: | @@ -114,106 +122,39 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install wasm-bindgen test runner + if: matrix.adapter == 'cloudflare' run: | - target="${{ steps.wasm-bindgen-version.outputs.version }}" - if ! wasm-bindgen --version 2>/dev/null | grep -qF "$target"; then - cargo install wasm-bindgen-cli --version "$target" --locked + required="${{ steps.wasm-bindgen-version.outputs.version }}" + if ! command -v wasm-bindgen-test-runner &>/dev/null \ + || ! wasm-bindgen --version 2>/dev/null | grep -q "$required"; then + cargo install wasm-bindgen-cli --version "$required" --locked --force fi - - name: Fetch dependencies (locked) - run: cargo fetch --locked - - - name: Run Cloudflare wasm tests - env: - CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner - run: cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract - - - name: Check Cloudflare wasm target - run: cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown - - fastly-wasm-tests: - name: fastly wasm tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Cache Cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-fastly-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-fastly- - - - name: Retrieve Rust version - id: rust-version-fastly - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - - name: Set up Rust tool chain - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: ${{ steps.rust-version-fastly.outputs.rust-version }} - - - name: Add wasm targets - run: rustup target add wasm32-wasip1 - - name: Setup Viceroy - run: cargo install viceroy --locked - - - name: Fetch dependencies (locked) - run: cargo fetch --locked + if: matrix.adapter == 'fastly' + run: | + if ! command -v viceroy &>/dev/null; then + cargo install viceroy --locked + fi - - name: Run Fastly wasm tests + - name: Setup Wasmtime + if: matrix.adapter == 'spin' env: - CARGO_TARGET_WASM32_WASIP1_RUNNER: "viceroy run" - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract - - - name: Check Fastly wasm target - run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 - - spin-adapter-tests: - name: spin adapter tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Cache Cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-spin-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-spin- - - - name: Retrieve Rust version - id: rust-version-spin - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: ${{ steps.rust-version-spin.outputs.rust-version }} - - - name: Add wasm32-wasip1 target - run: rustup target add wasm32-wasip1 + WASMTIME_VERSION: v44.0.0 + run: | + if ! command -v wasmtime &>/dev/null \ + || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then + curl https://wasmtime.dev/install.sh -sSf | bash -s -- "$WASMTIME_VERSION" + echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" + fi - name: Fetch dependencies (locked) run: cargo fetch --locked - - name: Run Spin adapter native tests - run: cargo test -p edgezero-adapter-spin --features spin + - name: Run ${{ matrix.adapter }} wasm tests + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract - - name: Check Spin wasm32 compilation - run: cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin + - name: Check ${{ matrix.adapter }} wasm target + run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index 84089f5..7aeb126 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -42,14 +42,12 @@ impl Default for SpinConfigStore { } impl ConfigStore for SpinConfigStore { - // `key` is unused in the _Uninhabited arm on native non-test builds - #[allow(unused_variables)] - fn get(&self, key: &str) -> Result, ConfigStoreError> { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { match &self.inner { #[cfg(target_arch = "wasm32")] SpinConfigBackend::Spin => { use spin_sdk::variables; - match variables::get(key) { + match variables::get(_key) { Ok(value) => Ok(Some(value)), Err(variables::Error::Undefined(_)) => Ok(None), Err(variables::Error::InvalidName(msg)) => { @@ -59,7 +57,7 @@ impl ConfigStore for SpinConfigStore { } } #[cfg(test)] - SpinConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), + SpinConfigBackend::InMemory(data) => Ok(data.get(_key).cloned()), #[cfg(not(any(target_arch = "wasm32", test)))] SpinConfigBackend::_Uninhabited(never) => match *never {}, } diff --git a/crates/edgezero-adapter-spin/src/key_value_store.rs b/crates/edgezero-adapter-spin/src/key_value_store.rs index d5d1940..292ae15 100644 --- a/crates/edgezero-adapter-spin/src/key_value_store.rs +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -9,14 +9,15 @@ //! `put_bytes_with_ttl` store the value without expiry and emit a //! `log::warn!`. //! - **Listing**: `spin_sdk::key_value::Store::get_keys()` returns all keys -//! with no prefix or cursor support. All keys are fetched from the host, -//! then prefix filtering and pagination are performed in-process. A -//! configurable cap (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) -//! limits how many keys may be processed; when the store contains more keys -//! than the cap, `list_keys_page` returns `KvError::Validation` so the -//! caller can detect the condition and raise the cap via -//! [`SpinKvStore::with_max_list_keys`] rather than silently receiving -//! incomplete results. +//! with no prefix or cursor support. Every call to `list_keys_page` pays a +//! full host round-trip that fetches **all** keys in the store regardless of +//! prefix or page size — O(n) I/O per page. Prefix filtering and pagination +//! are performed in-process after the fetch. A configurable cap +//! (`max_list_keys`, default [`DEFAULT_MAX_LIST_KEYS`]) limits how many keys +//! may be processed; when the store contains more keys than the cap, +//! `list_keys_page` returns `KvError::Validation` so the caller can detect +//! the condition and raise the cap via [`SpinKvStore::with_max_list_keys`] +//! rather than silently receiving incomplete results. //! //! # Note //! @@ -155,9 +156,9 @@ impl KvStore for SpinKvStore { 0 }; - let remaining = &keys[start..]; - let page_keys: Vec = remaining.iter().take(limit).cloned().collect(); - let has_more = remaining.len() > limit; + let tail = &keys[start..]; + let page_keys: Vec = tail.iter().take(limit).cloned().collect(); + let has_more = tail.len() > limit; let next_cursor = if has_more { page_keys.last().cloned() } else { diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index b28d080..50c863e 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -18,14 +18,15 @@ mod response; // allowing the InMemory test backend to compile on all targets. SpinKvStore // and SpinSecretStore import spin_sdk types at the module level and therefore // require `all(feature = "spin", target_arch = "wasm32")`. -pub use config_store::SpinConfigStore; -pub use context::SpinRequestContext; #[cfg(all(feature = "spin", target_arch = "wasm32"))] mod key_value_store; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub use key_value_store::SpinKvStore; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] mod secret_store; + +pub use config_store::SpinConfigStore; +pub use context::SpinRequestContext; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use key_value_store::SpinKvStore; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub use proxy::SpinProxyClient; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index c8a305f..e606089 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -95,9 +95,24 @@ fn find_header_string(entries: &[(String, Vec)], name: &str) -> Option anyhow::Result { let mut core_request = into_core_request(req).await?; diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index d26924d..f99d184 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -45,9 +45,13 @@ impl SecretStore for SpinSecretStore { store_name ); } - // Spin variable names are always lowercase. Normalise the key so that - // conventional uppercase secret names (e.g. "STRIPE_KEY") work without - // callers needing to know the Spin naming convention. + // Spin variable names must be lowercase. Normalise via ascii_lowercase + // so that SCREAMING_SNAKE_CASE keys (e.g. "STRIPE_KEY" → "stripe_key") + // work without callers knowing the Spin convention. Note: only + // UPPER_SNAKE → lower_snake is safe; camelCase or mixed-case keys will + // be lowercased in a way that may not match any declared variable + // (e.g. "stripeKey" → "stripekey"). Document accepted key formats at + // the call site. let lower = key.to_ascii_lowercase(); match variables::get(&lower) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index fb27673..247c1ac 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -13,20 +13,36 @@ use futures::executor::block_on; use futures::stream; use std::sync::Arc; -struct FixedConfigStore(&'static str); +/// Config store that returns a value only for the expected key. +struct FixedConfigStore { + key: &'static str, + value: &'static str, +} impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_string())) + fn get(&self, key: &str) -> Result, ConfigStoreError> { + if key == self.key { + Ok(Some(self.value.to_string())) + } else { + Ok(None) + } } } -struct StubKvStore; +/// KV store that returns a fixed value for one key; everything else is absent. +struct FixedKvStore { + key: &'static str, + value: &'static [u8], +} #[async_trait::async_trait(?Send)] -impl KvStore for StubKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) +impl KvStore for FixedKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + if key == self.key { + Ok(Some(Bytes::from_static(self.value))) + } else { + Ok(None) + } } async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { Ok(()) @@ -42,8 +58,8 @@ impl KvStore for StubKvStore { async fn delete(&self, _key: &str) -> Result<(), KvError> { Ok(()) } - async fn exists(&self, _key: &str) -> Result { - Ok(false) + async fn exists(&self, key: &str) -> Result { + Ok(key == self.key) } async fn list_keys_page( &self, @@ -52,18 +68,26 @@ impl KvStore for StubKvStore { _limit: usize, ) -> Result { Ok(KvPage { - keys: vec![], + keys: vec![self.key.to_string()], cursor: None, }) } } -struct StubSecretStore; +/// Secret store that returns a fixed value for one (store, key) pair. +struct FixedSecretStore { + key: &'static str, + value: &'static [u8], +} #[async_trait::async_trait(?Send)] -impl SecretStore for StubSecretStore { - async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { - Ok(None) +impl SecretStore for FixedSecretStore { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + if key == self.key { + Ok(Some(Bytes::from_static(self.value))) + } else { + Ok(None) + } } } @@ -111,28 +135,36 @@ fn build_test_app() -> App { Ok(response) } - async fn kv_presence(ctx: RequestContext) -> Result { - let body = Body::text(if ctx.kv_handle().is_some() { - "yes" + async fn kv_value(ctx: RequestContext) -> Result { + let value = if let Some(handle) = ctx.kv_handle() { + match handle.get_bytes("test-key").await { + Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(), + Ok(None) => "missing".to_string(), + Err(_) => "error".to_string(), + } } else { - "no" - }); + "no-handle".to_string() + }; let response = response_builder() .status(StatusCode::OK) - .body(body) + .body(Body::text(value)) .expect("response"); Ok(response) } - async fn secret_presence(ctx: RequestContext) -> Result { - let body = Body::text(if ctx.secret_handle().is_some() { - "yes" + async fn secret_value(ctx: RequestContext) -> Result { + let value = if let Some(handle) = ctx.secret_handle() { + match handle.get_bytes("default", "test-secret").await { + Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(), + Ok(None) => "missing".to_string(), + Err(_) => "error".to_string(), + } } else { - "no" - }); + "no-handle".to_string() + }; let response = response_builder() .status(StatusCode::OK) - .body(body) + .body(Body::text(value)) .expect("response"); Ok(response) } @@ -142,8 +174,8 @@ fn build_test_app() -> App { .post("/mirror", mirror_body) .get("/stream", stream_response) .get("/config", config_value) - .get("/has-kv", kv_presence) - .get("/has-secret", secret_presence) + .get("/kv-value", kv_value) + .get("/secret-value", secret_value) .build(); App::new(router) @@ -231,7 +263,7 @@ fn router_dispatches_streaming_route() { // --------------------------------------------------------------------------- #[test] -fn config_store_handle_is_accessible_from_handler() { +fn config_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") @@ -240,9 +272,10 @@ fn config_store_handle_is_accessible_from_handler() { .expect("request"); request .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore( - "hello-spin", - )))); + .insert(ConfigStoreHandle::new(Arc::new(FixedConfigStore { + key: "greeting", + value: "hello-spin", + }))); let response = block_on(app.router().oneshot(request)); @@ -251,39 +284,45 @@ fn config_store_handle_is_accessible_from_handler() { } #[test] -fn kv_handle_is_accessible_from_handler() { +fn kv_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") - .uri("http://example.com/has-kv") + .uri("http://example.com/kv-value") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(KvHandle::new(Arc::new(StubKvStore))); + .insert(KvHandle::new(Arc::new(FixedKvStore { + key: "test-key", + value: b"kv-payload", + }))); let response = block_on(app.router().oneshot(request)); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"yes"); + assert_eq!(response.body().as_bytes(), b"kv-payload"); } #[test] -fn secret_handle_is_accessible_from_handler() { +fn secret_store_reads_value_from_handler() { let app = build_test_app(); let mut request = request_builder() .method("GET") - .uri("http://example.com/has-secret") + .uri("http://example.com/secret-value") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(SecretHandle::new(Arc::new(StubSecretStore))); + .insert(SecretHandle::new(Arc::new(FixedSecretStore { + key: "test-secret", + value: b"s3cr3t", + }))); let response = block_on(app.router().oneshot(request)); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"yes"); + assert_eq!(response.body().as_bytes(), b"s3cr3t"); } #[test] @@ -295,24 +334,30 @@ fn missing_store_handles_return_absent_values_in_handler() { .uri("http://example.com/config") .body(Body::empty()) .expect("request"); - let config_response = block_on(app.router().oneshot(config_req)); - assert_eq!(config_response.body().as_bytes(), b"missing"); + assert_eq!( + block_on(app.router().oneshot(config_req)).body().as_bytes(), + b"missing" + ); let kv_req = request_builder() .method("GET") - .uri("http://example.com/has-kv") + .uri("http://example.com/kv-value") .body(Body::empty()) .expect("request"); - let kv_response = block_on(app.router().oneshot(kv_req)); - assert_eq!(kv_response.body().as_bytes(), b"no"); + assert_eq!( + block_on(app.router().oneshot(kv_req)).body().as_bytes(), + b"no-handle" + ); let secret_req = request_builder() .method("GET") - .uri("http://example.com/has-secret") + .uri("http://example.com/secret-value") .body(Body::empty()) .expect("request"); - let secret_response = block_on(app.router().oneshot(secret_req)); - assert_eq!(secret_response.body().as_bytes(), b"no"); + assert_eq!( + block_on(app.router().oneshot(secret_req)).body().as_bytes(), + b"no-handle" + ); } // --------------------------------------------------------------------------- From 46442252958b4c632c0fced86523d1a9727384bd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 14:54:02 +0530 Subject: [PATCH 18/19] Fix pin viceroy pin and wasmtime install flag --- .github/workflows/test.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bc6c89..e29f70e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,9 +132,12 @@ jobs: - name: Setup Viceroy if: matrix.adapter == 'fastly' + env: + VICEROY_VERSION: 0.16.5 run: | - if ! command -v viceroy &>/dev/null; then - cargo install viceroy --locked + if ! command -v viceroy &>/dev/null \ + || ! viceroy --version 2>/dev/null | grep -qF "$VICEROY_VERSION"; then + cargo install viceroy --version "$VICEROY_VERSION" --locked fi - name: Setup Wasmtime @@ -144,7 +147,7 @@ jobs: run: | if ! command -v wasmtime &>/dev/null \ || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then - curl https://wasmtime.dev/install.sh -sSf | bash -s -- "$WASMTIME_VERSION" + curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version "$WASMTIME_VERSION" echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" fi From ff0633bee9a81488dc5bbe38d30c2f6ddef922a6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Apr 2026 15:05:29 +0530 Subject: [PATCH 19/19] Fix type mismatch for wasm32-wasip1 --- crates/edgezero-adapter-spin/tests/contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 247c1ac..f943287 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -387,7 +387,7 @@ mod wasm { assert_eq!(*spin_response.status(), 201); let header = spin_response .headers() - .find(|(name, _)| name == "x-edgezero-res"); + .find(|(name, _)| *name == "x-edgezero-res"); assert!(header.is_some()); }); }