1use crate::error::Result;
8use crate::provider::{BindingsProvider, LazyEnvBindingsProvider};
9use crate::refreshing::{RefreshingKv, RefreshingQueue, RefreshingStorage, RefreshingVault};
10use crate::traits::{BindingsProviderApi, Kv, Queue, Storage, Vault};
11use std::collections::HashMap;
12use std::sync::Arc;
13
14#[derive(Debug)]
44pub struct Bindings {
45 provider: Arc<LazyEnvBindingsProvider>,
46}
47
48impl Bindings {
49 pub fn from_env() -> Result<Self> {
51 Self::from_env_map(std::env::vars().collect())
52 }
53
54 pub fn from_env_map(env: HashMap<String, String>) -> Result<Self> {
63 Ok(Self {
64 provider: Arc::new(BindingsProvider::from_env_deferred(env)?),
65 })
66 }
67
68 pub async fn storage(&self, binding_name: &str) -> Result<Arc<dyn Storage>> {
75 let initial = self.provider.load_storage(binding_name).await?;
76 Ok(Arc::new(RefreshingStorage::new(
77 self.provider.clone(),
78 binding_name.to_string(),
79 initial,
80 )))
81 }
82
83 pub async fn kv(&self, binding_name: &str) -> Result<Arc<dyn Kv>> {
85 self.provider.load_kv(binding_name).await?;
86 Ok(Arc::new(RefreshingKv::new(
87 self.provider.clone(),
88 binding_name.to_string(),
89 )))
90 }
91
92 pub async fn queue(&self, binding_name: &str) -> Result<Arc<dyn Queue>> {
94 self.provider.load_queue(binding_name).await?;
95 Ok(Arc::new(RefreshingQueue::new(
96 self.provider.clone(),
97 binding_name.to_string(),
98 )))
99 }
100
101 pub async fn vault(&self, binding_name: &str) -> Result<Arc<dyn Vault>> {
103 self.provider.load_vault(binding_name).await?;
104 Ok(Arc::new(RefreshingVault::new(
105 self.provider.clone(),
106 binding_name.to_string(),
107 )))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::net::SocketAddr;
115 use std::sync::atomic::{AtomicUsize, Ordering};
116
117 use crate::error::binding_env_var;
118 use crate::traits::MessagePayload;
119 use alien_core::{
120 Platform, ENV_ALIEN_DEPLOYMENT_ID, ENV_ALIEN_DEPLOYMENT_SERVICE_ACCOUNT,
121 ENV_ALIEN_DEPLOYMENT_TOKEN, ENV_ALIEN_DEPLOYMENT_TYPE, ENV_ALIEN_MANAGER_URL,
122 ENV_ALIEN_RESOURCE_ID,
123 };
124 use axum::{extract::State, routing::post, Json, Router};
125 use object_store::{path::Path as ObjectPath, PutPayload};
126 use std::collections::HashMap;
127 use tempfile::TempDir;
128
129 fn base_env() -> HashMap<String, String> {
131 HashMap::from([(
132 ENV_ALIEN_DEPLOYMENT_TYPE.to_string(),
133 Platform::Local.as_str().to_string(),
134 )])
135 }
136
137 fn with_binding(
138 mut env: HashMap<String, String>,
139 binding_name: &str,
140 json: &str,
141 ) -> HashMap<String, String> {
142 env.insert(binding_env_var(binding_name), json.to_string());
143 env
144 }
145
146 #[derive(Clone)]
147 struct MintServerState {
148 calls: Arc<AtomicUsize>,
149 state_directory: String,
150 }
151
152 async fn mint_handler(State(state): State<MintServerState>) -> Json<serde_json::Value> {
153 let call = state.calls.fetch_add(1, Ordering::SeqCst) + 1;
154 let lifetime_seconds = if call == 1 { 120 } else { 3600 };
155 let expires_at =
156 (chrono::Utc::now() + chrono::Duration::seconds(lifetime_seconds)).to_rfc3339();
157 Json(serde_json::json!({
158 "clientConfig": {
159 "platform": "local",
160 "state_directory": state.state_directory,
161 },
162 "expiresAt": expires_at,
163 "principal": "local:refreshing-binding-test",
164 }))
165 }
166
167 async fn spawn_mint_server(state_directory: &str) -> (String, Arc<AtomicUsize>) {
168 let calls = Arc::new(AtomicUsize::new(0));
169 let app = Router::new()
170 .route("/v1/credentials/mint", post(mint_handler))
171 .with_state(MintServerState {
172 calls: calls.clone(),
173 state_directory: state_directory.to_string(),
174 });
175 let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
176 .await
177 .expect("bind fake mint server");
178 let address = listener.local_addr().expect("read fake server address");
179 tokio::spawn(async move {
180 axum::serve(listener, app)
181 .await
182 .expect("serve fake mint endpoint");
183 });
184 (format!("http://{address}"), calls)
185 }
186
187 fn mint_env(manager_url: &str) -> HashMap<String, String> {
188 HashMap::from([
189 (
190 ENV_ALIEN_DEPLOYMENT_TYPE.to_string(),
191 Platform::Aws.as_str().to_string(),
192 ),
193 ("AWS_EC2_METADATA_DISABLED".to_string(), "true".to_string()),
194 (
195 "AWS_PROFILE".to_string(),
196 "__alien_missing_refresh_test_profile__".to_string(),
197 ),
198 (ENV_ALIEN_MANAGER_URL.to_string(), manager_url.to_string()),
199 (
200 ENV_ALIEN_DEPLOYMENT_TOKEN.to_string(),
201 "refresh-test-token".to_string(),
202 ),
203 (
204 ENV_ALIEN_DEPLOYMENT_ID.to_string(),
205 "refresh-test-deployment".to_string(),
206 ),
207 (
208 ENV_ALIEN_DEPLOYMENT_SERVICE_ACCOUNT.to_string(),
209 "refresh-test-service-account".to_string(),
210 ),
211 (
212 ENV_ALIEN_RESOURCE_ID.to_string(),
213 "refresh-test-resource".to_string(),
214 ),
215 ])
216 }
217
218 #[test]
219 fn from_env_map_constructs_synchronously_from_injected_env() {
220 let bindings =
223 Bindings::from_env_map(base_env()).expect("valid env should construct Bindings");
224 drop(bindings);
225 }
226
227 #[tokio::test]
228 async fn storage_delegates_to_local_provider_and_performs_real_io() {
229 let temp_dir = TempDir::new().expect("tempdir");
230 let json = format!(
231 r#"{{"service":"local-storage","storagePath":"{}"}}"#,
232 temp_dir.path().display()
233 );
234 let env = with_binding(base_env(), "files", &json);
235 let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
236
237 let storage = bindings
238 .storage("files")
239 .await
240 .expect("storage binding should load");
241
242 let path = ObjectPath::from("greeting.txt");
243 storage
244 .put(&path, PutPayload::from(bytes::Bytes::from_static(b"hello")))
245 .await
246 .expect("put should succeed");
247 let fetched = storage
248 .get(&path)
249 .await
250 .expect("get should succeed")
251 .bytes()
252 .await
253 .expect("reading bytes should succeed");
254 assert_eq!(fetched.as_ref(), b"hello");
255 }
256
257 #[tokio::test]
258 async fn kv_delegates_to_local_provider_and_performs_real_io() {
259 let temp_dir = TempDir::new().expect("tempdir");
260 let json = format!(
261 r#"{{"service":"local-kv","dataDir":"{}"}}"#,
262 temp_dir.path().display()
263 );
264 let env = with_binding(base_env(), "cache", &json);
265 let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
266
267 let kv = bindings.kv("cache").await.expect("kv binding should load");
268
269 kv.put("greeting", b"hi".to_vec(), None)
270 .await
271 .expect("put should succeed");
272 let value = kv
273 .get("greeting")
274 .await
275 .expect("get should succeed")
276 .expect("value should exist");
277 assert_eq!(value, b"hi");
278 }
279
280 #[tokio::test]
281 async fn long_lived_kv_handle_refreshes_minted_provider_before_expiry() {
282 let temp_dir = TempDir::new().expect("tempdir");
283 let (manager_url, calls) = spawn_mint_server(
284 temp_dir
285 .path()
286 .to_str()
287 .expect("tempdir path must be valid UTF-8"),
288 )
289 .await;
290 let json = format!(
291 r#"{{"service":"local-kv","dataDir":"{}"}}"#,
292 temp_dir.path().display()
293 );
294 let env = with_binding(mint_env(&manager_url), "cache", &json);
295 let bindings = Bindings::from_env_map(env).expect("minting env should construct Bindings");
296
297 let kv = bindings
298 .kv("cache")
299 .await
300 .expect("first binding resolution should mint credentials");
301 assert_eq!(calls.load(Ordering::SeqCst), 1);
302
303 kv.put("greeting", b"hi".to_vec(), None)
304 .await
305 .expect("the long-lived handle should refresh and write");
306 assert_eq!(
307 calls.load(Ordering::SeqCst),
308 2,
309 "the first mint is still unexpired but inside the refresh window"
310 );
311
312 let value = kv
313 .get("greeting")
314 .await
315 .expect("the same long-lived handle should read")
316 .expect("value should exist");
317 assert_eq!(value, b"hi");
318 assert_eq!(
319 calls.load(Ordering::SeqCst),
320 2,
321 "the refreshed provider should stay cached while fresh"
322 );
323 }
324
325 #[tokio::test]
326 async fn queue_delegates_to_local_provider_and_performs_real_io() {
327 let temp_dir = TempDir::new().expect("tempdir");
328 let json = format!(
329 r#"{{"service":"local-queue","queuePath":"{}"}}"#,
330 temp_dir.path().join("queue.db").display()
331 );
332 let env = with_binding(base_env(), "jobs", &json);
333 let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
334
335 let queue = bindings
336 .queue("jobs")
337 .await
338 .expect("queue binding should load");
339
340 queue
341 .send("jobs", MessagePayload::Text("hello".to_string()))
342 .await
343 .expect("send should succeed");
344 let messages = queue
345 .receive("jobs", 1)
346 .await
347 .expect("receive should succeed");
348 assert_eq!(messages.len(), 1);
349 }
350
351 #[tokio::test]
352 async fn queue_nack_and_purge_reachable_through_trait_object() {
353 let temp_dir = TempDir::new().expect("tempdir");
356 let json = format!(
357 r#"{{"service":"local-queue","queuePath":"{}"}}"#,
358 temp_dir.path().join("queue.db").display()
359 );
360 let env = with_binding(base_env(), "jobs", &json);
361 let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
362
363 let queue = bindings
364 .queue("jobs")
365 .await
366 .expect("queue binding should load");
367
368 queue
371 .send("jobs", MessagePayload::Text("retry".to_string()))
372 .await
373 .expect("send should succeed");
374 let first = queue
375 .receive("jobs", 1)
376 .await
377 .expect("receive should succeed");
378 assert_eq!(first.len(), 1);
379 assert!(
380 queue
381 .receive("jobs", 1)
382 .await
383 .expect("receive should succeed")
384 .is_empty(),
385 "in-flight message must be hidden before nack"
386 );
387 queue
388 .nack("jobs", &first[0].receipt_handle)
389 .await
390 .expect("nack should succeed");
391 let redelivered = queue
392 .receive("jobs", 1)
393 .await
394 .expect("receive should succeed");
395 assert_eq!(redelivered.len(), 1, "nacked message must be redelivered");
396
397 queue.purge("jobs").await.expect("purge should succeed");
399 assert!(
400 queue
401 .receive("jobs", 1)
402 .await
403 .expect("receive should succeed")
404 .is_empty(),
405 "purge must empty the queue"
406 );
407 }
408
409 #[tokio::test]
410 async fn vault_delegates_to_local_provider_and_performs_real_io() {
411 let temp_dir = TempDir::new().expect("tempdir");
412 let json = format!(
413 r#"{{"service":"local-vault","vaultName":"secrets","dataDir":"{}"}}"#,
414 temp_dir.path().display()
415 );
416 let env = with_binding(base_env(), "secrets", &json);
417 let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
418
419 let vault = bindings
420 .vault("secrets")
421 .await
422 .expect("vault binding should load");
423
424 vault
425 .set_secret("api-key", "sekrit")
426 .await
427 .expect("set_secret should succeed");
428 let value = vault
429 .get_secret("api-key")
430 .await
431 .expect("get_secret should succeed");
432 assert_eq!(value, "sekrit");
433
434 vault
437 .set_secret("db-url", "postgres://…")
438 .await
439 .expect("set_secret should succeed");
440 let mut names = vault
441 .list_secrets()
442 .await
443 .expect("list_secrets should succeed");
444 names.sort();
445 assert_eq!(names, vec!["api-key".to_string(), "db-url".to_string()]);
446 }
447
448 #[tokio::test]
449 async fn missing_storage_binding_returns_binding_not_configured() {
450 let bindings = Bindings::from_env_map(base_env())
451 .expect("construction should succeed with no bindings configured");
452
453 let error = bindings
454 .storage("files")
455 .await
456 .expect_err("missing binding should error");
457
458 assert_eq!(error.code, "BINDING_NOT_CONFIGURED");
459 assert!(
460 error.to_string().contains("ALIEN_FILES_BINDING"),
461 "message should name the env var, got: {error}"
462 );
463 }
464
465 #[tokio::test]
466 async fn zero_env_construct_then_missing_binding_is_binding_not_configured() {
467 for kind in ["storage", "kv", "queue", "vault"] {
476 let bindings = Bindings::from_env_map(HashMap::new())
477 .expect("zero-env construction must succeed (platform resolution deferred)");
478
479 let error = match kind {
480 "storage" => bindings.storage("x").await.unwrap_err(),
481 "kv" => bindings.kv("x").await.unwrap_err(),
482 "queue" => bindings.queue("x").await.unwrap_err(),
483 "vault" => bindings.vault("x").await.unwrap_err(),
484 other => unreachable!("unhandled kind in table test: {other}"),
485 };
486
487 assert_eq!(
488 error.code, "BINDING_NOT_CONFIGURED",
489 "{kind}: expected the missing-binding error, not a platform/deployment error: {error}"
490 );
491 assert!(
492 error.to_string().contains("ALIEN_X_BINDING"),
493 "{kind}: message should name the env var, got: {error}"
494 );
495 }
496 }
497
498 #[test]
499 fn malformed_binding_json_returns_binding_config_invalid_naming_env_var() {
500 let env = with_binding(base_env(), "files", "not-json");
501
502 let error =
503 Bindings::from_env_map(env).expect_err("malformed binding JSON should fail to load");
504
505 assert_eq!(error.code, "BINDING_CONFIG_INVALID");
506 assert!(
507 error.to_string().contains("ALIEN_FILES_BINDING"),
508 "message should name the env var, got: {error}"
509 );
510 }
511
512 #[tokio::test]
513 async fn redis_kv_binding_returns_unsupported_binding_provider() {
514 let json = r#"{"service":"redis","connectionUrl":"redis://localhost:6379"}"#;
515 let env = with_binding(base_env(), "cache", json);
516 let bindings = Bindings::from_env_map(env).expect("valid JSON should construct");
517
518 let error = bindings
519 .kv("cache")
520 .await
521 .expect_err("redis is not a supported kv provider in this build");
522
523 assert_eq!(error.code, "UNSUPPORTED_BINDING_PROVIDER");
524 assert!(
525 error.to_string().contains("ALIEN_CACHE_BINDING"),
526 "message should name the env var, got: {error}"
527 );
528 }
529}