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