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