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