Skip to main content

alien_bindings/
bindings.rs

1//! App-facing convenience API for accessing bindings.
2//!
3//! [`Bindings`] wraps a [`crate::provider::LazyEnvBindingsProvider`], giving application
4//! code a small, stable surface — `storage`, `kv`, `queue`, `vault` — instead of the full
5//! [`crate::traits::BindingsProviderApi`] used internally by the manager and controllers.
6
7use crate::error::Result;
8use crate::provider::{BindingsProvider, LazyEnvBindingsProvider};
9use crate::refreshing::{RefreshingKv, RefreshingQueue, RefreshingStorage, RefreshingVault};
10use crate::traits::{BindingsProviderApi, Kv, Queue, Storage, Vault};
11use std::collections::HashMap;
12use std::sync::Arc;
13
14/// App-facing entry point for accessing bindings configured via `ALIEN_*_BINDING`
15/// environment variables.
16///
17/// Construction is synchronous and only validates each configured binding's JSON
18/// shape (see [`BindingsProvider::from_env_deferred`]); the deployment platform,
19/// cloud client configuration, and each binding's backing client are resolved
20/// lazily, on first use. A first operation against a binding that is not
21/// configured reports `BINDING_NOT_CONFIGURED` before any platform resolution, so
22/// a zero-environment process still constructs and fails cleanly.
23///
24/// # Examples
25///
26/// This is the canonical usage for a Container/Daemon-shaped app (a long-running
27/// resident process that only needs bindings, with no Worker event handlers):
28///
29/// ```no_run
30/// use alien_bindings::Bindings;
31/// use object_store::{path::Path, PutPayload};
32///
33/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
34/// let bindings = Bindings::from_env()?;
35///
36/// let storage = bindings.storage("files").await?;
37/// storage
38///     .put(&Path::from("greeting.txt"), PutPayload::from_static(b"hello"))
39///     .await?;
40/// # Ok(())
41/// # }
42/// ```
43#[derive(Debug)]
44pub struct Bindings {
45    provider: Arc<LazyEnvBindingsProvider>,
46}
47
48impl Bindings {
49    /// Sync-constructs `Bindings` from the current process environment.
50    pub fn from_env() -> Result<Self> {
51        Self::from_env_map(std::env::vars().collect())
52    }
53
54    /// Sync-constructs `Bindings` from an explicit environment map instead of the process
55    /// environment.
56    ///
57    /// This is public for embedders that resolve bindings from a caller-supplied map rather
58    /// than `std::env` — notably the napi addon, which merges `std::env::vars()` with
59    /// per-call overrides before constructing `Bindings`. It is also what `from_env`
60    /// delegates to and what this module's tests use to inject `ALIEN_*_BINDING` variables
61    /// (avoiding process-global state that's unsafe to share across parallel tests).
62    pub fn from_env_map(env: HashMap<String, String>) -> Result<Self> {
63        Ok(Self {
64            provider: Arc::new(BindingsProvider::from_env_deferred(env)?),
65        })
66    }
67
68    /// Loads the object storage binding named `binding_name`.
69    ///
70    /// The returned handle checks credential freshness before each operation.
71    /// Native credentials and fresh minted credentials remain cached; a minted
72    /// provider inside its refresh window is re-minted once under the shared
73    /// resolver's single-flight guard.
74    pub async fn storage(&self, binding_name: &str) -> Result<Arc<dyn Storage>> {
75        let initial = self.provider.load_storage(binding_name).await?;
76        Ok(Arc::new(RefreshingStorage::new(
77            self.provider.clone(),
78            binding_name.to_string(),
79            initial,
80        )))
81    }
82
83    /// Loads a key-value binding that refreshes minted credentials before use.
84    pub async fn kv(&self, binding_name: &str) -> Result<Arc<dyn Kv>> {
85        self.provider.load_kv(binding_name).await?;
86        Ok(Arc::new(RefreshingKv::new(
87            self.provider.clone(),
88            binding_name.to_string(),
89        )))
90    }
91
92    /// Loads a queue binding that refreshes minted credentials before use.
93    pub async fn queue(&self, binding_name: &str) -> Result<Arc<dyn Queue>> {
94        self.provider.load_queue(binding_name).await?;
95        Ok(Arc::new(RefreshingQueue::new(
96            self.provider.clone(),
97            binding_name.to_string(),
98        )))
99    }
100
101    /// Loads a vault binding that refreshes minted credentials before use.
102    pub async fn vault(&self, binding_name: &str) -> Result<Arc<dyn Vault>> {
103        self.provider.load_vault(binding_name).await?;
104        Ok(Arc::new(RefreshingVault::new(
105            self.provider.clone(),
106            binding_name.to_string(),
107        )))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::net::SocketAddr;
115    use std::sync::atomic::{AtomicUsize, Ordering};
116
117    use crate::error::binding_env_var;
118    use crate::traits::MessagePayload;
119    use alien_core::{
120        Platform, ENV_ALIEN_DEPLOYMENT_ID, ENV_ALIEN_DEPLOYMENT_SERVICE_ACCOUNT,
121        ENV_ALIEN_DEPLOYMENT_TOKEN, ENV_ALIEN_DEPLOYMENT_TYPE, ENV_ALIEN_MANAGER_URL,
122        ENV_ALIEN_RESOURCE_ID,
123    };
124    use axum::{extract::State, routing::post, Json, Router};
125    use object_store::{path::Path as ObjectPath, PutPayload};
126    use std::collections::HashMap;
127    use tempfile::TempDir;
128
129    /// Minimal valid environment (no bindings configured yet).
130    fn base_env() -> HashMap<String, String> {
131        HashMap::from([(
132            ENV_ALIEN_DEPLOYMENT_TYPE.to_string(),
133            Platform::Local.as_str().to_string(),
134        )])
135    }
136
137    fn with_binding(
138        mut env: HashMap<String, String>,
139        binding_name: &str,
140        json: &str,
141    ) -> HashMap<String, String> {
142        env.insert(binding_env_var(binding_name), json.to_string());
143        env
144    }
145
146    #[derive(Clone)]
147    struct MintServerState {
148        calls: Arc<AtomicUsize>,
149        state_directory: String,
150    }
151
152    async fn mint_handler(State(state): State<MintServerState>) -> Json<serde_json::Value> {
153        let call = state.calls.fetch_add(1, Ordering::SeqCst) + 1;
154        let lifetime_seconds = if call == 1 { 120 } else { 3600 };
155        let expires_at =
156            (chrono::Utc::now() + chrono::Duration::seconds(lifetime_seconds)).to_rfc3339();
157        Json(serde_json::json!({
158            "clientConfig": {
159                "platform": "local",
160                "state_directory": state.state_directory,
161            },
162            "expiresAt": expires_at,
163            "principal": "local:refreshing-binding-test",
164        }))
165    }
166
167    async fn spawn_mint_server(state_directory: &str) -> (String, Arc<AtomicUsize>) {
168        let calls = Arc::new(AtomicUsize::new(0));
169        let app = Router::new()
170            .route("/v1/credentials/mint", post(mint_handler))
171            .with_state(MintServerState {
172                calls: calls.clone(),
173                state_directory: state_directory.to_string(),
174            });
175        let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
176            .await
177            .expect("bind fake mint server");
178        let address = listener.local_addr().expect("read fake server address");
179        tokio::spawn(async move {
180            axum::serve(listener, app)
181                .await
182                .expect("serve fake mint endpoint");
183        });
184        (format!("http://{address}"), calls)
185    }
186
187    fn mint_env(manager_url: &str) -> HashMap<String, String> {
188        HashMap::from([
189            (
190                ENV_ALIEN_DEPLOYMENT_TYPE.to_string(),
191                Platform::Aws.as_str().to_string(),
192            ),
193            ("AWS_EC2_METADATA_DISABLED".to_string(), "true".to_string()),
194            (
195                "AWS_PROFILE".to_string(),
196                "__alien_missing_refresh_test_profile__".to_string(),
197            ),
198            (ENV_ALIEN_MANAGER_URL.to_string(), manager_url.to_string()),
199            (
200                ENV_ALIEN_DEPLOYMENT_TOKEN.to_string(),
201                "refresh-test-token".to_string(),
202            ),
203            (
204                ENV_ALIEN_DEPLOYMENT_ID.to_string(),
205                "refresh-test-deployment".to_string(),
206            ),
207            (
208                ENV_ALIEN_DEPLOYMENT_SERVICE_ACCOUNT.to_string(),
209                "refresh-test-service-account".to_string(),
210            ),
211            (
212                ENV_ALIEN_RESOURCE_ID.to_string(),
213                "refresh-test-resource".to_string(),
214            ),
215        ])
216    }
217
218    #[test]
219    fn from_env_map_constructs_synchronously_from_injected_env() {
220        // No `.await` here at all: proves construction is a plain sync function,
221        // not something that merely returns a Future.
222        let bindings =
223            Bindings::from_env_map(base_env()).expect("valid env should construct Bindings");
224        drop(bindings);
225    }
226
227    #[tokio::test]
228    async fn storage_delegates_to_local_provider_and_performs_real_io() {
229        let temp_dir = TempDir::new().expect("tempdir");
230        let json = format!(
231            r#"{{"service":"local-storage","storagePath":"{}"}}"#,
232            temp_dir.path().display()
233        );
234        let env = with_binding(base_env(), "files", &json);
235        let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
236
237        let storage = bindings
238            .storage("files")
239            .await
240            .expect("storage binding should load");
241
242        let path = ObjectPath::from("greeting.txt");
243        storage
244            .put(&path, PutPayload::from(bytes::Bytes::from_static(b"hello")))
245            .await
246            .expect("put should succeed");
247        let fetched = storage
248            .get(&path)
249            .await
250            .expect("get should succeed")
251            .bytes()
252            .await
253            .expect("reading bytes should succeed");
254        assert_eq!(fetched.as_ref(), b"hello");
255    }
256
257    #[tokio::test]
258    async fn kv_delegates_to_local_provider_and_performs_real_io() {
259        let temp_dir = TempDir::new().expect("tempdir");
260        let json = format!(
261            r#"{{"service":"local-kv","dataDir":"{}"}}"#,
262            temp_dir.path().display()
263        );
264        let env = with_binding(base_env(), "cache", &json);
265        let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
266
267        let kv = bindings.kv("cache").await.expect("kv binding should load");
268
269        kv.put("greeting", b"hi".to_vec(), None)
270            .await
271            .expect("put should succeed");
272        let value = kv
273            .get("greeting")
274            .await
275            .expect("get should succeed")
276            .expect("value should exist");
277        assert_eq!(value, b"hi");
278    }
279
280    #[tokio::test]
281    async fn long_lived_kv_handle_refreshes_minted_provider_before_expiry() {
282        let temp_dir = TempDir::new().expect("tempdir");
283        let (manager_url, calls) = spawn_mint_server(
284            temp_dir
285                .path()
286                .to_str()
287                .expect("tempdir path must be valid UTF-8"),
288        )
289        .await;
290        let json = format!(
291            r#"{{"service":"local-kv","dataDir":"{}"}}"#,
292            temp_dir.path().display()
293        );
294        let env = with_binding(mint_env(&manager_url), "cache", &json);
295        let bindings = Bindings::from_env_map(env).expect("minting env should construct Bindings");
296
297        let kv = bindings
298            .kv("cache")
299            .await
300            .expect("first binding resolution should mint credentials");
301        assert_eq!(calls.load(Ordering::SeqCst), 1);
302
303        kv.put("greeting", b"hi".to_vec(), None)
304            .await
305            .expect("the long-lived handle should refresh and write");
306        assert_eq!(
307            calls.load(Ordering::SeqCst),
308            2,
309            "the first mint is still unexpired but inside the refresh window"
310        );
311
312        let value = kv
313            .get("greeting")
314            .await
315            .expect("the same long-lived handle should read")
316            .expect("value should exist");
317        assert_eq!(value, b"hi");
318        assert_eq!(
319            calls.load(Ordering::SeqCst),
320            2,
321            "the refreshed provider should stay cached while fresh"
322        );
323    }
324
325    #[tokio::test]
326    async fn queue_delegates_to_local_provider_and_performs_real_io() {
327        let temp_dir = TempDir::new().expect("tempdir");
328        let json = format!(
329            r#"{{"service":"local-queue","queuePath":"{}"}}"#,
330            temp_dir.path().join("queue.db").display()
331        );
332        let env = with_binding(base_env(), "jobs", &json);
333        let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
334
335        let queue = bindings
336            .queue("jobs")
337            .await
338            .expect("queue binding should load");
339
340        queue
341            .send("jobs", MessagePayload::Text("hello".to_string()))
342            .await
343            .expect("send should succeed");
344        let messages = queue
345            .receive("jobs", 1)
346            .await
347            .expect("receive should succeed");
348        assert_eq!(messages.len(), 1);
349    }
350
351    #[tokio::test]
352    async fn queue_nack_and_purge_reachable_through_trait_object() {
353        // The point of promoting nack/purge onto the Queue trait: they must be
354        // callable on the `Arc<dyn Queue>` the app-facing API hands back.
355        let temp_dir = TempDir::new().expect("tempdir");
356        let json = format!(
357            r#"{{"service":"local-queue","queuePath":"{}"}}"#,
358            temp_dir.path().join("queue.db").display()
359        );
360        let env = with_binding(base_env(), "jobs", &json);
361        let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
362
363        let queue = bindings
364            .queue("jobs")
365            .await
366            .expect("queue binding should load");
367
368        // nack: an in-flight message under the default lease is hidden, but a
369        // nack makes it immediately redeliverable.
370        queue
371            .send("jobs", MessagePayload::Text("retry".to_string()))
372            .await
373            .expect("send should succeed");
374        let first = queue
375            .receive("jobs", 1)
376            .await
377            .expect("receive should succeed");
378        assert_eq!(first.len(), 1);
379        assert!(
380            queue
381                .receive("jobs", 1)
382                .await
383                .expect("receive should succeed")
384                .is_empty(),
385            "in-flight message must be hidden before nack"
386        );
387        queue
388            .nack("jobs", &first[0].receipt_handle)
389            .await
390            .expect("nack should succeed");
391        let redelivered = queue
392            .receive("jobs", 1)
393            .await
394            .expect("receive should succeed");
395        assert_eq!(redelivered.len(), 1, "nacked message must be redelivered");
396
397        // purge: clears everything, in flight or visible.
398        queue.purge("jobs").await.expect("purge should succeed");
399        assert!(
400            queue
401                .receive("jobs", 1)
402                .await
403                .expect("receive should succeed")
404                .is_empty(),
405            "purge must empty the queue"
406        );
407    }
408
409    #[tokio::test]
410    async fn vault_delegates_to_local_provider_and_performs_real_io() {
411        let temp_dir = TempDir::new().expect("tempdir");
412        let json = format!(
413            r#"{{"service":"local-vault","vaultName":"secrets","dataDir":"{}"}}"#,
414            temp_dir.path().display()
415        );
416        let env = with_binding(base_env(), "secrets", &json);
417        let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
418
419        let vault = bindings
420            .vault("secrets")
421            .await
422            .expect("vault binding should load");
423
424        vault
425            .set_secret("api-key", "sekrit")
426            .await
427            .expect("set_secret should succeed");
428        let value = vault
429            .get_secret("api-key")
430            .await
431            .expect("get_secret should succeed");
432        assert_eq!(value, "sekrit");
433
434        // list_secrets must be reachable through the `Arc<dyn Vault>` surface
435        // and return the stored names.
436        vault
437            .set_secret("db-url", "postgres://…")
438            .await
439            .expect("set_secret should succeed");
440        let mut names = vault
441            .list_secrets()
442            .await
443            .expect("list_secrets should succeed");
444        names.sort();
445        assert_eq!(names, vec!["api-key".to_string(), "db-url".to_string()]);
446    }
447
448    #[tokio::test]
449    async fn missing_storage_binding_returns_binding_not_configured() {
450        let bindings = Bindings::from_env_map(base_env())
451            .expect("construction should succeed with no bindings configured");
452
453        let error = bindings
454            .storage("files")
455            .await
456            .expect_err("missing binding should error");
457
458        assert_eq!(error.code, "BINDING_NOT_CONFIGURED");
459        assert!(
460            error.to_string().contains("ALIEN_FILES_BINDING"),
461            "message should name the env var, got: {error}"
462        );
463    }
464
465    #[tokio::test]
466    async fn zero_env_construct_then_missing_binding_is_binding_not_configured() {
467        // The app-facing contract: with NO deployment type and NO credentials,
468        // construction must succeed and the FIRST op on a missing binding must
469        // report BINDING_NOT_CONFIGURED (naming ALIEN_<NAME>_BINDING) BEFORE any
470        // platform / client-config resolution. There is deliberately no
471        // ALIEN_DEPLOYMENT_TYPE in this environment. Table test over all four
472        // app-facing kinds so a future kind added to `Bindings` without wiring
473        // `ensure_binding_present` into its `load_*` method fails this test
474        // instead of silently regressing to ENVIRONMENT_VARIABLE_MISSING.
475        for kind in ["storage", "kv", "queue", "vault"] {
476            let bindings = Bindings::from_env_map(HashMap::new())
477                .expect("zero-env construction must succeed (platform resolution deferred)");
478
479            let error = match kind {
480                "storage" => bindings.storage("x").await.unwrap_err(),
481                "kv" => bindings.kv("x").await.unwrap_err(),
482                "queue" => bindings.queue("x").await.unwrap_err(),
483                "vault" => bindings.vault("x").await.unwrap_err(),
484                other => unreachable!("unhandled kind in table test: {other}"),
485            };
486
487            assert_eq!(
488                error.code, "BINDING_NOT_CONFIGURED",
489                "{kind}: expected the missing-binding error, not a platform/deployment error: {error}"
490            );
491            assert!(
492                error.to_string().contains("ALIEN_X_BINDING"),
493                "{kind}: message should name the env var, got: {error}"
494            );
495        }
496    }
497
498    #[test]
499    fn malformed_binding_json_returns_binding_config_invalid_naming_env_var() {
500        let env = with_binding(base_env(), "files", "not-json");
501
502        let error =
503            Bindings::from_env_map(env).expect_err("malformed binding JSON should fail to load");
504
505        assert_eq!(error.code, "BINDING_CONFIG_INVALID");
506        assert!(
507            error.to_string().contains("ALIEN_FILES_BINDING"),
508            "message should name the env var, got: {error}"
509        );
510    }
511
512    #[tokio::test]
513    async fn redis_kv_binding_returns_unsupported_binding_provider() {
514        let json = r#"{"service":"redis","connectionUrl":"redis://localhost:6379"}"#;
515        let env = with_binding(base_env(), "cache", json);
516        let bindings = Bindings::from_env_map(env).expect("valid JSON should construct");
517
518        let error = bindings
519            .kv("cache")
520            .await
521            .expect_err("redis is not a supported kv provider in this build");
522
523        assert_eq!(error.code, "UNSUPPORTED_BINDING_PROVIDER");
524        assert!(
525            error.to_string().contains("ALIEN_CACHE_BINDING"),
526            "message should name the env var, got: {error}"
527        );
528    }
529}