1use 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#[derive(Debug)]
46pub struct Bindings {
47 provider: Arc<LazyEnvBindingsProvider>,
48}
49
50#[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 pub async fn send(&self, message: MessagePayload) -> Result<()> {
76 self.inner.send(&self.name, message).await
77 }
78
79 pub async fn receive(&self, max_messages: usize) -> Result<Vec<QueueMessage>> {
81 self.inner.receive(&self.name, max_messages).await
82 }
83
84 pub async fn ack(&self, receipt_handle: &str) -> Result<()> {
86 self.inner.ack(&self.name, receipt_handle).await
87 }
88
89 pub async fn nack(&self, receipt_handle: &str) -> Result<()> {
91 self.inner.nack(&self.name, receipt_handle).await
92 }
93
94 pub async fn purge(&self) -> Result<()> {
96 self.inner.purge(&self.name).await
97 }
98}
99
100impl Bindings {
101 pub fn from_env() -> Result<Self> {
103 Self::from_env_map(std::env::vars().collect())
104 }
105
106 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 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 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 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 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 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 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 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 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 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 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 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}