1use 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#[derive(Debug)]
48pub struct Bindings {
49 provider: Arc<LazyEnvBindingsProvider>,
50}
51
52#[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 pub async fn send(&self, message: MessagePayload) -> Result<()> {
78 self.inner.send(&self.name, message).await
79 }
80
81 pub async fn receive(&self, max_messages: usize) -> Result<Vec<QueueMessage>> {
83 self.inner.receive(&self.name, max_messages).await
84 }
85
86 pub async fn ack(&self, receipt_handle: &str) -> Result<()> {
88 self.inner.ack(&self.name, receipt_handle).await
89 }
90
91 pub async fn nack(&self, receipt_handle: &str) -> Result<()> {
93 self.inner.nack(&self.name, receipt_handle).await
94 }
95
96 pub async fn purge(&self) -> Result<()> {
98 self.inner.purge(&self.name).await
99 }
100}
101
102impl Bindings {
103 pub fn from_env() -> Result<Self> {
105 Self::from_env_map(std::env::vars().collect())
106 }
107
108 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 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 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 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 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 pub async fn container(&self, binding_name: &str) -> Result<Arc<dyn Container>> {
167 self.provider.load_container(binding_name).await
168 }
169
170 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 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 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 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 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 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 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}