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