diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c87283..e29f70e 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,61 +122,42 @@ 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 - - - name: Fetch dependencies (locked) - run: cargo fetch --locked + if: matrix.adapter == 'cloudflare' + run: | + 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: Run Cloudflare wasm tests + - name: Setup Viceroy + if: matrix.adapter == 'fastly' 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 + VICEROY_VERSION: 0.16.5 + run: | + 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 Viceroy - run: cargo install viceroy --locked + - name: Setup Wasmtime + if: matrix.adapter == 'spin' + env: + 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 -- --version "$WASMTIME_VERSION" + echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" + fi - name: Fetch dependencies (locked) run: cargo fetch --locked - - name: Run Fastly wasm tests + - name: Run ${{ matrix.adapter }} wasm tests env: - CARGO_TARGET_WASM32_WASIP1_RUNNER: "viceroy run" - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract - - name: Check Fastly wasm target - run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 + - name: Check ${{ matrix.adapter }} wasm target + run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} 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 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..7aeb126 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -0,0 +1,82 @@ +//! 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: SpinConfigBackend, +} + +enum SpinConfigBackend { + #[cfg(target_arch = "wasm32")] + Spin, + #[cfg(test)] + InMemory(std::collections::HashMap), + /// Never constructed; keeps the enum inhabited in non-wasm32, non-test builds. + #[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: SpinConfigBackend::Spin, + } + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { + inner: SpinConfigBackend::InMemory(entries.into_iter().collect()), + } + } +} + +#[cfg(target_arch = "wasm32")] +impl Default for SpinConfigStore { + fn default() -> Self { + Self::new() + } +} + +impl ConfigStore for SpinConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(target_arch = "wasm32")] + SpinConfigBackend::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)] + SpinConfigBackend::InMemory(data) => Ok(data.get(_key).cloned()), + #[cfg(not(any(target_arch = "wasm32", test)))] + SpinConfigBackend::_Uninhabited(never) => match *never {}, + } + } +} + +#[cfg(test)] +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()), + ("contract.key.b".to_string(), "value_b".to_string()), + ]) + }); +} 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..292ae15 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/key_value_store.rs @@ -0,0 +1,176 @@ +//! 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. 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 +//! +//! 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; + +/// 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"))] +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 +/// `Store::open(label)`. +#[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"))] +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, + 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 allowed during `list_keys_page`. + /// + /// When the Spin KV store contains more than `limit` 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 + } +} + +#[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 all_keys = self + .store + .get_keys() + .map_err(|e| KvError::Internal(anyhow::anyhow!("get_keys failed: {e}")))?; + + if all_keys.len() > self.max_list_keys { + 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, + ))); + } + + let mut keys: Vec = all_keys + .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 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 { + 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 9722fb5..50c863e 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"))] @@ -12,13 +13,28 @@ 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")`. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod key_value_store; +#[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"))] 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. /// @@ -68,11 +84,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, diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 736bb2c..e606089 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,52 @@ 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 new file mode 100644 index 0000000..f99d184 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -0,0 +1,68 @@ +//! 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; + 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 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()))), + 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}" + ))), + } + } +} + +// TODO: integration tests require the Spin runtime. +// Test SpinSecretStore as part of a Spin E2E test suite. diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 2df70de..f943287 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -2,12 +2,94 @@ 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; + +/// 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> { + if key == self.key { + Ok(Some(self.value.to_string())) + } else { + Ok(None) + } + } +} + +/// 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 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(()) + } + 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(key == self.key) + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: vec![self.key.to_string()], + cursor: None, + }) + } +} + +/// 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 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) + } + } +} fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { @@ -41,10 +123,59 @@ 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_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-handle".to_string() + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + + 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-handle".to_string() + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .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("/kv-value", kv_value) + .get("/secret-value", secret_value) .build(); App::new(router) @@ -127,6 +258,108 @@ 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_reads_value_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 { + key: "greeting", + value: "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_store_reads_value_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/kv-value") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .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"kv-payload"); +} + +#[test] +fn secret_store_reads_value_from_handler() { + let app = build_test_app(); + let mut request = request_builder() + .method("GET") + .uri("http://example.com/secret-value") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .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"s3cr3t"); +} + +#[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"); + 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/kv-value") + .body(Body::empty()) + .expect("request"); + 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/secret-value") + .body(Body::empty()) + .expect("request"); + assert_eq!( + block_on(app.router().oneshot(secret_req)).body().as_bytes(), + b"no-handle" + ); +} + // --------------------------------------------------------------------------- // Tests that require `spin_sdk` types (wasm32 + spin feature only) // @@ -154,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()); }); } @@ -192,3 +425,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::(); + } +} 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>; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 571a496..c6f7e27 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 { @@ -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"))] @@ -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" ); } 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