Skip to main content

alien_core/bindings/
mod.rs

1//! Type-safe binding parameter definitions
2//!
3//! This module defines structs that represent the runtime parameters needed by bindings
4//! to interact with cloud resources. These structs are used by:
5//! - Controllers when returning binding parameters directly
6//! - Template generators when creating CloudFormation/Terraform templates (using Fn::ToJsonString)
7//! - Bindings when consuming runtime parameters (parsing JSON)
8//!
9//! This provides type safety and ensures consistency across all parts of the system.
10
11use crate::error::ErrorData;
12use alien_error::{AlienError, Context, IntoAlienError};
13use serde::{Deserialize, Serialize};
14use serde_json::Value as JsonValue;
15use std::collections::HashMap;
16
17mod ai;
18mod artifact_registry;
19mod build;
20mod container;
21mod container_apps_environment;
22mod key;
23mod kv;
24mod postgres;
25mod queue;
26mod sandbox;
27mod service_account;
28mod storage;
29mod vault;
30mod worker;
31
32pub use ai::{AiBinding, BedrockAiBinding, ExternalAiBinding, FoundryAiBinding, VertexAiBinding};
33pub use artifact_registry::{
34    AcrArtifactRegistryBinding, ArtifactRegistryBinding, EcrArtifactRegistryBinding,
35    GarArtifactRegistryBinding, LocalArtifactRegistryBinding, LEGACY_REPOSITORY_PUSH_REFUSAL,
36};
37pub use build::{
38    AcaBuildBinding, BuildBinding, CloudbuildBuildBinding, CodebuildBuildBinding, LocalBuildBinding,
39};
40pub use container::{
41    ContainerBinding, HorizonContainerBinding, KubernetesContainerBinding, LocalContainerBinding,
42};
43pub use container_apps_environment::ContainerAppsEnvironmentBinding;
44pub use key::{AwsKmsKeyBinding, AzureKeyVaultKeyBinding, GcpCloudKmsKeyBinding, KeyBinding};
45pub use kv::{
46    DynamodbKvBinding, FirestoreKvBinding, KvBinding, LocalKvBinding, RedisKvBinding,
47    TableStorageKvBinding,
48};
49pub use postgres::{
50    AuroraPostgresBinding, CloudSqlPostgresBinding, ExternalPostgresBinding,
51    ExternalPostgresSslMode, FlexibleServerPostgresBinding, LocalPostgresBinding, PostgresBinding,
52};
53pub use queue::{
54    LocalHttpQueueBinding, LocalQueueBinding, PubSubQueueBinding, QueueBinding,
55    ServiceBusQueueBinding, SqsQueueBinding,
56};
57pub use sandbox::{
58    AwsSandboxBinding, AzureSandboxBinding, GcpAgentPlatformSandboxBinding,
59    KubernetesSandboxBinding, LocalSandboxBinding, SandboxBinding, SandboxEgressBinding,
60    SandboxEgressGateway, SandboxEgressReplicas, SandboxEgressSigningKey,
61};
62pub use service_account::{
63    AwsServiceAccountBinding, AzureServiceAccountBinding, GcpServiceAccountBinding,
64    ServiceAccountBinding,
65};
66pub use storage::{
67    BlobStorageBinding, GcsStorageBinding, LocalStorageBinding, S3StorageBinding, StorageBinding,
68};
69pub use vault::{
70    KeyVaultBinding, KubernetesSecretVaultBinding, LocalVaultBinding, ParameterStoreVaultBinding,
71    SecretManagerVaultBinding, VaultBinding,
72};
73pub use worker::{
74    CloudRunWorkerBinding, ContainerAppWorkerBinding, KubernetesWorkerBinding, LambdaWorkerBinding,
75    LocalWorkerBinding, WorkerBinding,
76};
77
78/// Represents a value that can be either a concrete value, a template expression,
79/// or a reference to a Kubernetes Secret
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
83#[serde(untagged)]
84pub enum BindingValue<T> {
85    /// A concrete value (used by controllers)
86    Value(T),
87    /// A Kubernetes Secret reference (must come before Expression)
88    #[serde(rename_all = "camelCase")]
89    SecretRef { secret_ref: SecretReference },
90    /// A template expression (used by IaC template generators)
91    #[cfg_attr(feature = "jsonschema", schemars(skip))]
92    Expression(JsonValue),
93}
94
95/// Reference to a Kubernetes Secret
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
98#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
99#[serde(rename_all = "camelCase")]
100pub struct SecretReference {
101    pub name: String,
102    pub key: String,
103}
104
105impl<T> BindingValue<T> {
106    /// Creates a concrete value
107    pub fn value(val: T) -> Self {
108        Self::Value(val)
109    }
110
111    /// Creates a template expression
112    pub fn expression(expr: JsonValue) -> Self {
113        Self::Expression(expr)
114    }
115
116    /// Extracts the concrete value, returning an error if this is a template expression or SecretRef
117    pub fn into_value(self, binding_name: &str, field_name: &str) -> crate::error::Result<T> {
118        match self {
119            BindingValue::Value(val) => Ok(val),
120            BindingValue::Expression(_) => Err(AlienError::new(ErrorData::BindingConfigInvalid {
121                binding_name: binding_name.to_string(),
122                reason: format!("Template expressions not supported in runtime bindings for field '{}'", field_name),
123            })),
124            BindingValue::SecretRef { .. } => Err(AlienError::new(ErrorData::BindingConfigInvalid {
125                binding_name: binding_name.to_string(),
126                reason: format!("SecretRef not resolved for field '{}' - this should have been resolved by the controller", field_name),
127            }))
128        }
129    }
130}
131
132impl<T> From<T> for BindingValue<T> {
133    fn from(val: T) -> Self {
134        Self::Value(val)
135    }
136}
137
138impl From<&str> for BindingValue<String> {
139    fn from(val: &str) -> Self {
140        Self::Value(val.to_string())
141    }
142}
143
144impl From<JsonValue> for BindingValue<String> {
145    fn from(val: JsonValue) -> Self {
146        Self::Expression(val)
147    }
148}
149
150/// Helper worker to serialize binding struct as JSON for environment variables
151pub fn serialize_binding_as_env_var<T: Serialize>(
152    binding_name: &str,
153    binding: &T,
154) -> crate::error::Result<HashMap<String, String>> {
155    let mut env_vars = HashMap::new();
156    let key = binding_env_var_name(binding_name);
157    let mut value = serde_json::to_value(binding).into_alien_error().context(
158        ErrorData::BindingConfigInvalid {
159            binding_name: binding_name.to_string(),
160            reason: "Failed to serialize binding to JSON".to_string(),
161        },
162    )?;
163    // Environment strings become part of pod templates. Map insertion order
164    // must not restart a workload whose binding values have not changed.
165    value.sort_all_objects();
166    let binding_json = serde_json::to_string(&value).into_alien_error().context(
167        ErrorData::BindingConfigInvalid {
168            binding_name: binding_name.to_string(),
169            reason: "Failed to serialize binding to JSON".to_string(),
170        },
171    )?;
172    env_vars.insert(key, binding_json);
173    Ok(env_vars)
174}
175
176/// Helper worker to serialize binding struct for CloudFormation templates
177pub fn serialize_binding_for_template<T: Serialize>(
178    binding_name: &str,
179    binding: &T,
180) -> crate::error::Result<HashMap<String, JsonValue>> {
181    let mut env_vars = HashMap::new();
182    let key = binding_env_var_name(binding_name);
183    let binding_json = serde_json::to_value(binding).into_alien_error().context(
184        ErrorData::BindingConfigInvalid {
185            binding_name: binding_name.to_string(),
186            reason: "Failed to serialize binding to JSON for template".to_string(),
187        },
188    )?;
189
190    // Wrap in Fn::ToJsonString for CloudFormation
191    env_vars.insert(
192        key,
193        JsonValue::Object({
194            let mut map = serde_json::Map::new();
195            map.insert("Fn::ToJsonString".to_string(), binding_json);
196            map
197        }),
198    );
199
200    Ok(env_vars)
201}
202
203/// Helper worker to generate the environment variable name for a binding
204pub fn binding_env_var_name(binding_name: &str) -> String {
205    format!(
206        "ALIEN_{}_BINDING",
207        binding_name.replace('-', "_").to_uppercase()
208    )
209}
210
211/// Helper worker to parse binding from environment variable
212pub fn parse_binding_from_env<T: for<'de> Deserialize<'de>>(
213    env: &HashMap<String, String>,
214    binding_name: &str,
215) -> crate::error::Result<T> {
216    let key = binding_env_var_name(binding_name);
217    let json_str = env.get(&key).ok_or_else(|| {
218        AlienError::new(ErrorData::BindingEnvVarMissing {
219            binding_name: binding_name.to_string(),
220            env_var: key.clone(),
221        })
222    })?;
223
224    let mut value: JsonValue = serde_json::from_str(json_str).into_alien_error().context(
225        ErrorData::BindingJsonParseFailed {
226            binding_name: binding_name.to_string(),
227            reason: "Invalid JSON format".to_string(),
228        },
229    )?;
230    resolve_binding_env_refs(&mut value, env, binding_name)?;
231    serde_json::from_value(value)
232        .into_alien_error()
233        .context(ErrorData::BindingJsonParseFailed {
234            binding_name: binding_name.to_string(),
235            reason: "Invalid JSON format".to_string(),
236        })
237}
238
239/// Resolve projected Secret values after parsing JSON, preserving their exact bytes.
240/// Values read from the environment are literals, never recursively interpreted.
241pub fn resolve_binding_env_refs(
242    value: &mut JsonValue,
243    env: &HashMap<String, String>,
244    binding_name: &str,
245) -> crate::error::Result<()> {
246    match value {
247        JsonValue::Object(fields) => {
248            if fields.len() == 1 && fields.contains_key("envRef") {
249                let name = fields["envRef"].as_str().ok_or_else(|| {
250                    AlienError::new(ErrorData::BindingConfigInvalid {
251                        binding_name: binding_name.to_string(),
252                        reason: "envRef must name an environment variable".to_string(),
253                    })
254                })?;
255                let secret = env.get(name).ok_or_else(|| {
256                    AlienError::new(ErrorData::BindingEnvVarMissing {
257                        binding_name: binding_name.to_string(),
258                        env_var: name.to_string(),
259                    })
260                })?;
261                *value = JsonValue::String(secret.clone());
262            } else {
263                for field in fields.values_mut() {
264                    resolve_binding_env_refs(field, env, binding_name)?;
265                }
266            }
267        }
268        JsonValue::Array(items) => {
269            for item in items {
270                resolve_binding_env_refs(item, env, binding_name)?;
271            }
272        }
273        _ => {}
274    }
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::bindings::{ArtifactRegistryBinding, BuildBinding, StorageBinding};
282    use serde_json::json;
283    use std::collections::HashMap;
284
285    #[test]
286    fn environment_references_are_resolved_as_literals() {
287        let original = json!({"nested": [{"envRef": "SECRET"}], "literal": "$(SECRET)"});
288        let secret = "{\"envRef\":\"OTHER\"}\n\\";
289        let env = HashMap::from([("SECRET".into(), secret.into())]);
290        let mut value = original.clone();
291        resolve_binding_env_refs(&mut value, &env, "database").unwrap();
292        assert_eq!(value, json!({"nested": [secret], "literal": "$(SECRET)"}));
293        let mut missing = original;
294        assert!(resolve_binding_env_refs(&mut missing, &HashMap::new(), "database").is_err());
295        let mut invalid = json!({"envRef": 7});
296        assert!(resolve_binding_env_refs(&mut invalid, &env, "database").is_err());
297    }
298
299    #[test]
300    fn build_binding_environment_is_stable_across_map_constructions() {
301        let expected = r#"{"buildEnvVars":{"CACHE":"shared","MODE":"release","TARGET":"linux"},"namespace":"site","service":"kubernetes","serviceAccountName":"builder"}"#;
302        for _ in 0..16 {
303            let binding = BuildBinding::kubernetes(
304                "site",
305                "builder",
306                HashMap::from([
307                    ("MODE".to_string(), "release".to_string()),
308                    ("CACHE".to_string(), "shared".to_string()),
309                    ("TARGET".to_string(), "linux".to_string()),
310                ]),
311            );
312            let vars = serialize_binding_as_env_var("build", &binding).unwrap();
313            assert_eq!(vars["ALIEN_BUILD_BINDING"], expected);
314            let parsed: BuildBinding = parse_binding_from_env(&vars, "build").unwrap();
315            assert_eq!(parsed, binding);
316        }
317    }
318
319    #[test]
320    fn binding_environment_ignores_nested_object_order_but_preserves_arrays() {
321        let first =
322            json!({"service": "kubernetes", "nested": [{"z": 1, "a": 2}], "args": ["run", "app"]});
323        let second =
324            json!({"args": ["run", "app"], "nested": [{"a": 2, "z": 1}], "service": "kubernetes"});
325        let original = serialize_binding_as_env_var("build", &first).unwrap();
326        assert_eq!(
327            original,
328            serialize_binding_as_env_var("build", &second).unwrap()
329        );
330        let mut changed = second;
331        changed["args"] = json!(["app", "run"]);
332        assert_ne!(
333            original,
334            serialize_binding_as_env_var("build", &changed).unwrap()
335        );
336        assert_eq!(
337            serde_json::from_str::<JsonValue>(&original["ALIEN_BUILD_BINDING"]).unwrap(),
338            first
339        );
340    }
341
342    #[test]
343    fn test_serialize_storage_binding_as_env_var() {
344        let binding = StorageBinding::s3("my-bucket");
345
346        let env_vars = serialize_binding_as_env_var("TEST", &binding).unwrap();
347
348        assert_eq!(env_vars.len(), 1);
349        let json_str = env_vars.get("ALIEN_TEST_BINDING").unwrap();
350        let parsed: StorageBinding = serde_json::from_str(json_str).unwrap();
351        assert_eq!(binding, parsed);
352    }
353
354    #[test]
355    fn test_serialize_binding_for_template() {
356        let binding = StorageBinding::S3(S3StorageBinding::bucket(BindingValue::expression(
357            json!({"Ref": "MyBucket"}),
358        )));
359
360        let env_vars = serialize_binding_for_template("TEST", &binding).unwrap();
361
362        assert_eq!(env_vars.len(), 1);
363        let fn_to_json_string = env_vars.get("ALIEN_TEST_BINDING").unwrap();
364
365        // Should be wrapped in Fn::ToJsonString
366        assert!(fn_to_json_string.get("Fn::ToJsonString").is_some());
367    }
368
369    #[test]
370    fn test_artifact_registry_binding_roundtrip() {
371        let binding = ArtifactRegistryBinding::ecr(
372            "my-project",
373            Some("arn:aws:iam::123456789012:role/PullRole".to_string()),
374            None::<String>,
375        );
376
377        let env_vars = serialize_binding_as_env_var("TEST", &binding).unwrap();
378
379        let reconstructed: ArtifactRegistryBinding =
380            parse_binding_from_env(&env_vars, "TEST").unwrap();
381        assert_eq!(binding, reconstructed);
382    }
383
384    #[test]
385    fn test_service_type_serialization() {
386        // Test S3 storage
387        let s3_binding = StorageBinding::s3("my-bucket");
388        let s3_json = serde_json::to_string(&s3_binding).unwrap();
389        assert!(s3_json.contains(r#""service":"s3""#));
390
391        // Test ECR registry
392        let ecr_binding = ArtifactRegistryBinding::ecr("my-repo", None::<String>, None::<String>);
393        let ecr_json = serde_json::to_string(&ecr_binding).unwrap();
394        assert!(ecr_json.contains(r#""service":"ecr""#));
395
396        // Test CodeBuild
397        let build_binding = BuildBinding::codebuild("my-project", HashMap::new(), None);
398        let build_json = serde_json::to_string(&build_binding).unwrap();
399        assert!(build_json.contains(r#""service":"codebuild""#));
400    }
401
402    #[test]
403    fn test_cross_provider_bindings() {
404        // Test that we can mix different service types in one environment
405        let storage_binding = StorageBinding::s3("prod-bucket");
406        let registry_binding = ArtifactRegistryBinding::acr("myregistry", "mygroup");
407        let build_binding = BuildBinding::cloudbuild(
408            HashMap::new(),
409            "build@project.iam.gserviceaccount.com",
410            None,
411        );
412
413        // Serialize all bindings
414        let storage_env = serialize_binding_as_env_var("STORAGE", &storage_binding).unwrap();
415        let registry_env = serialize_binding_as_env_var("REGISTRY", &registry_binding).unwrap();
416        let build_env = serialize_binding_as_env_var("BUILD", &build_binding).unwrap();
417
418        // All should work together
419        assert!(storage_env.contains_key("ALIEN_STORAGE_BINDING"));
420        assert!(registry_env.contains_key("ALIEN_REGISTRY_BINDING"));
421        assert!(build_env.contains_key("ALIEN_BUILD_BINDING"));
422    }
423
424    #[test]
425    fn test_binding_value_secret_ref() {
426        // Test SecretRef serialization
427        let secret_ref: BindingValue<String> = BindingValue::SecretRef {
428            secret_ref: SecretReference {
429                name: "my-secret".to_string(),
430                key: "password".to_string(),
431            },
432        };
433
434        let json = serde_json::to_string(&secret_ref).unwrap();
435        assert!(json.contains(r#""secretRef""#));
436        assert!(json.contains(r#""name":"my-secret""#));
437        assert!(json.contains(r#""key":"password""#));
438
439        // Test deserialization
440        let parsed: BindingValue<String> = serde_json::from_str(&json).unwrap();
441        assert_eq!(secret_ref, parsed);
442    }
443
444    #[test]
445    fn test_binding_value_into_value_secret_ref() {
446        let secret_ref: BindingValue<String> = BindingValue::SecretRef {
447            secret_ref: SecretReference {
448                name: "my-secret".to_string(),
449                key: "password".to_string(),
450            },
451        };
452
453        let result = secret_ref.into_value("test", "password");
454        assert!(result.is_err());
455        assert!(result
456            .unwrap_err()
457            .to_string()
458            .contains("SecretRef not resolved"));
459    }
460
461    #[test]
462    fn test_binding_value_variants() {
463        // Test Value variant
464        let value: BindingValue<String> = BindingValue::value("test".to_string());
465        assert_eq!(value.into_value("test", "field").unwrap(), "test");
466
467        // Test Expression variant
468        let expr: BindingValue<String> = BindingValue::expression(json!({"Ref": "Test"}));
469        assert!(expr.into_value("test", "field").is_err());
470    }
471}