Skip to main content

alien_core/
external_bindings.rs

1//! External bindings for pre-existing infrastructure services.
2//!
3//! External bindings allow using existing infrastructure (MinIO, Kafka, Redis, etc.)
4//! instead of having Alien provision cloud resources. This is required for Kubernetes
5//! platform deployments and optional for cloud platforms (to override specific resources).
6
7use std::collections::HashMap;
8
9use alien_error::AlienError;
10use serde::{Deserialize, Serialize};
11
12use crate::bindings::{
13    ArtifactRegistryBinding, BindingValue, ContainerAppsEnvironmentBinding, ExternalAiBinding,
14    KvBinding, PostgresBinding, QueueBinding, StorageBinding, VaultBinding,
15};
16use crate::error::ErrorData;
17use crate::resource::ResourceOutputs;
18use crate::resources::AzureContainerAppsEnvironmentOutputs;
19use crate::Resource;
20
21/// Represents a binding to pre-existing infrastructure.
22///
23/// The binding type must match the resource type it's applied to.
24/// Validated at runtime by the executor.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum ExternalBinding {
29    /// External storage binding (S3-compatible, GCS, Blob Storage)
30    Storage(StorageBinding),
31    /// External queue binding (Kafka, SQS, etc.)
32    Queue(QueueBinding),
33    /// External KV binding (Redis, etc.)
34    Kv(KvBinding),
35    /// External artifact registry binding (OCI registry)
36    ArtifactRegistry(ArtifactRegistryBinding),
37    /// External vault binding (HashiCorp Vault, etc.)
38    Vault(VaultBinding),
39    /// External Azure Container Apps Environment binding (pre-existing environment)
40    ContainerAppsEnvironment(ContainerAppsEnvironmentBinding),
41    /// External Postgres binding (operator-provided / BYO database)
42    Postgres(PostgresBinding),
43    /// External AI provider binding (BYO-key OpenAI/Anthropic, etc.)
44    Ai(ExternalAiBinding),
45}
46
47/// Map from resource ID to external binding.
48///
49/// Validated at runtime: binding type must match resource type.
50#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52#[serde(transparent)]
53pub struct ExternalBindings(pub HashMap<String, ExternalBinding>);
54
55impl ExternalBindings {
56    /// Creates an empty ExternalBindings map.
57    pub fn new() -> Self {
58        Self(HashMap::new())
59    }
60
61    /// Returns true if there are no external bindings.
62    pub fn is_empty(&self) -> bool {
63        self.0.is_empty()
64    }
65
66    /// Checks if a binding exists for the given resource ID.
67    pub fn has(&self, resource_id: &str) -> bool {
68        self.0.contains_key(resource_id)
69    }
70
71    /// Gets an external binding by resource ID.
72    pub fn get(&self, resource_id: &str) -> Option<&ExternalBinding> {
73        self.0.get(resource_id)
74    }
75
76    /// Gets a storage binding for the given resource ID.
77    /// Returns an error if the binding exists but is not a Storage type.
78    pub fn get_storage(&self, id: &str) -> crate::error::Result<Option<&StorageBinding>> {
79        match self.0.get(id) {
80            Some(ExternalBinding::Storage(b)) => Ok(Some(b)),
81            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
82                resource_id: id.to_string(),
83                expected: "storage".to_string(),
84                actual: other.binding_type().to_string(),
85            })),
86            None => Ok(None),
87        }
88    }
89
90    /// Gets a queue binding for the given resource ID.
91    /// Returns an error if the binding exists but is not a Queue type.
92    pub fn get_queue(&self, id: &str) -> crate::error::Result<Option<&QueueBinding>> {
93        match self.0.get(id) {
94            Some(ExternalBinding::Queue(b)) => Ok(Some(b)),
95            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
96                resource_id: id.to_string(),
97                expected: "queue".to_string(),
98                actual: other.binding_type().to_string(),
99            })),
100            None => Ok(None),
101        }
102    }
103
104    /// Gets a KV binding for the given resource ID.
105    /// Returns an error if the binding exists but is not a Kv type.
106    pub fn get_kv(&self, id: &str) -> crate::error::Result<Option<&KvBinding>> {
107        match self.0.get(id) {
108            Some(ExternalBinding::Kv(b)) => Ok(Some(b)),
109            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
110                resource_id: id.to_string(),
111                expected: "kv".to_string(),
112                actual: other.binding_type().to_string(),
113            })),
114            None => Ok(None),
115        }
116    }
117
118    /// Gets an artifact registry binding for the given resource ID.
119    /// Returns an error if the binding exists but is not an ArtifactRegistry type.
120    pub fn get_artifact_registry(
121        &self,
122        id: &str,
123    ) -> crate::error::Result<Option<&ArtifactRegistryBinding>> {
124        match self.0.get(id) {
125            Some(ExternalBinding::ArtifactRegistry(b)) => Ok(Some(b)),
126            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
127                resource_id: id.to_string(),
128                expected: "artifact_registry".to_string(),
129                actual: other.binding_type().to_string(),
130            })),
131            None => Ok(None),
132        }
133    }
134
135    /// Gets a vault binding for the given resource ID.
136    /// Returns an error if the binding exists but is not a Vault type.
137    pub fn get_vault(&self, id: &str) -> crate::error::Result<Option<&VaultBinding>> {
138        match self.0.get(id) {
139            Some(ExternalBinding::Vault(b)) => Ok(Some(b)),
140            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
141                resource_id: id.to_string(),
142                expected: "vault".to_string(),
143                actual: other.binding_type().to_string(),
144            })),
145            None => Ok(None),
146        }
147    }
148
149    /// Gets an AI binding for the given resource ID.
150    /// Returns an error if the binding exists but is not an Ai type.
151    pub fn get_ai(&self, id: &str) -> crate::error::Result<Option<&ExternalAiBinding>> {
152        match self.0.get(id) {
153            Some(ExternalBinding::Ai(b)) => Ok(Some(b)),
154            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
155                resource_id: id.to_string(),
156                expected: "ai".to_string(),
157                actual: other.binding_type().to_string(),
158            })),
159            None => Ok(None),
160        }
161    }
162
163    /// Gets a container apps environment binding for the given resource ID.
164    /// Returns an error if the binding exists but is not a ContainerAppsEnvironment type.
165    pub fn get_container_apps_environment(
166        &self,
167        id: &str,
168    ) -> crate::error::Result<Option<&ContainerAppsEnvironmentBinding>> {
169        match self.0.get(id) {
170            Some(ExternalBinding::ContainerAppsEnvironment(b)) => Ok(Some(b)),
171            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
172                resource_id: id.to_string(),
173                expected: "azure_container_apps_environment".to_string(),
174                actual: other.binding_type().to_string(),
175            })),
176            None => Ok(None),
177        }
178    }
179
180    /// Returns an error if the binding exists but is not a Postgres type.
181    pub fn get_postgres(&self, id: &str) -> crate::error::Result<Option<&PostgresBinding>> {
182        match self.0.get(id) {
183            Some(ExternalBinding::Postgres(b)) => Ok(Some(b)),
184            Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
185                resource_id: id.to_string(),
186                expected: "postgres".to_string(),
187                actual: other.binding_type().to_string(),
188            })),
189            None => Ok(None),
190        }
191    }
192
193    /// Inserts an external binding for a resource.
194    pub fn insert(&mut self, resource_id: impl Into<String>, binding: ExternalBinding) {
195        self.0.insert(resource_id.into(), binding);
196    }
197}
198
199impl ExternalBinding {
200    /// Returns the type name of this binding variant.
201    pub fn binding_type(&self) -> &'static str {
202        match self {
203            ExternalBinding::Storage(_) => "storage",
204            ExternalBinding::Queue(_) => "queue",
205            ExternalBinding::Kv(_) => "kv",
206            ExternalBinding::ArtifactRegistry(_) => "artifact_registry",
207            ExternalBinding::Vault(_) => "vault",
208            ExternalBinding::ContainerAppsEnvironment(_) => "azure_container_apps_environment",
209            ExternalBinding::Postgres(_) => "postgres",
210            ExternalBinding::Ai(_) => "ai",
211        }
212    }
213
214    /// Converts this external binding into resource outputs that dependent resources
215    /// can read via `get_resource_outputs()`.
216    ///
217    /// Infrastructure bindings (Container Apps Environment) produce typed outputs so that
218    /// dependent resources like functions and builds can read the environment's name,
219    /// resource ID, and resource group. Application-level bindings (Storage, Queue, KV, etc.)
220    /// return `None` — they are consumed via `remote_binding_params` and environment variables
221    /// rather than `get_resource_outputs()`.
222    pub fn to_resource_outputs(&self) -> Option<ResourceOutputs> {
223        match self {
224            ExternalBinding::ContainerAppsEnvironment(binding) => {
225                // Extract concrete values from BindingValue wrappers.
226                // External bindings for pre-provisioned resources always use concrete values.
227                let environment_name = match &binding.environment_name {
228                    BindingValue::Value(v) => v.clone(),
229                    _ => return None,
230                };
231                let resource_id = match &binding.resource_id {
232                    BindingValue::Value(v) => v.clone(),
233                    _ => return None,
234                };
235                let resource_group_name = match &binding.resource_group_name {
236                    BindingValue::Value(v) => v.clone(),
237                    _ => return None,
238                };
239                let default_domain = match &binding.default_domain {
240                    BindingValue::Value(v) => v.clone(),
241                    _ => return None,
242                };
243                let static_ip = binding.static_ip.as_ref().and_then(|v| match v {
244                    BindingValue::Value(v) => Some(v.clone()),
245                    _ => None,
246                });
247
248                Some(ResourceOutputs::new(AzureContainerAppsEnvironmentOutputs {
249                    environment_name,
250                    resource_id,
251                    resource_group_name,
252                    default_domain,
253                    static_ip,
254                    custom_domain_verification_id: None,
255                }))
256            }
257            // Application-level bindings are consumed via remote_binding_params, not outputs
258            _ => None,
259        }
260    }
261
262    /// Serializes this external binding to the JSON value injected into the worker
263    /// environment as `ALIEN_<NAME>_BINDING`, matching the shape the SDK parses.
264    ///
265    /// The AI arm wraps the carried `ExternalAiBinding` in `AiBinding::External` so
266    /// the value keeps the `service` tag the SDK's `ai(name)` parser discriminates
267    /// on; a bare `ExternalAiBinding` would lack it. Every other arm serializes the
268    /// carried binding directly, like the runtime controllers' `get_binding_params`.
269    pub fn to_env_binding_value(&self) -> serde_json::Result<serde_json::Value> {
270        match self {
271            ExternalBinding::Storage(b) => serde_json::to_value(b),
272            ExternalBinding::Queue(b) => serde_json::to_value(b),
273            ExternalBinding::Kv(b) => serde_json::to_value(b),
274            ExternalBinding::ArtifactRegistry(b) => serde_json::to_value(b),
275            ExternalBinding::Vault(b) => serde_json::to_value(b),
276            ExternalBinding::ContainerAppsEnvironment(b) => serde_json::to_value(b),
277            ExternalBinding::Postgres(b) => serde_json::to_value(b),
278            ExternalBinding::Ai(b) => {
279                serde_json::to_value(crate::bindings::AiBinding::External(b.clone()))
280            }
281        }
282    }
283}
284
285/// Validates that an external binding type matches the resource type.
286pub fn validate_binding_type(
287    resource: &Resource,
288    binding: &ExternalBinding,
289) -> crate::error::Result<()> {
290    let resource_type = resource.resource_type();
291    let resource_type_str = resource_type.as_ref();
292
293    let valid = match (resource_type_str, binding) {
294        ("storage", ExternalBinding::Storage(_)) => true,
295        ("queue", ExternalBinding::Queue(_)) => true,
296        ("kv", ExternalBinding::Kv(_)) => true,
297        ("artifact_registry", ExternalBinding::ArtifactRegistry(_)) => true,
298        ("vault", ExternalBinding::Vault(_)) => true,
299        ("azure_container_apps_environment", ExternalBinding::ContainerAppsEnvironment(_)) => true,
300        ("postgres", ExternalBinding::Postgres(_)) => true,
301        ("ai", ExternalBinding::Ai(_)) => true,
302        _ => false,
303    };
304
305    if !valid {
306        return Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch {
307            resource_id: resource.id().to_string(),
308            expected: resource_type_str.to_string(),
309            actual: binding.binding_type().to_string(),
310        }));
311    }
312    Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::bindings::{KvBinding, StorageBinding};
319
320    #[test]
321    fn test_external_bindings_storage() {
322        let mut bindings = ExternalBindings::new();
323        bindings.insert(
324            "data-storage",
325            ExternalBinding::Storage(StorageBinding::s3("my-bucket")),
326        );
327
328        assert!(bindings.has("data-storage"));
329        assert!(bindings.get_storage("data-storage").unwrap().is_some());
330        assert!(bindings.get_queue("data-storage").is_err()); // Wrong type
331    }
332
333    #[test]
334    fn test_external_bindings_kv() {
335        let mut bindings = ExternalBindings::new();
336        bindings.insert(
337            "cache",
338            ExternalBinding::Kv(KvBinding::redis("redis://localhost:6379")),
339        );
340
341        assert!(bindings.has("cache"));
342        assert!(bindings.get_kv("cache").unwrap().is_some());
343        assert!(bindings.get_storage("cache").is_err()); // Wrong type
344    }
345
346    #[test]
347    fn test_external_bindings_ai() {
348        use crate::bindings::ExternalAiBinding;
349
350        let mut bindings = ExternalBindings::new();
351        bindings.insert(
352            "llm",
353            ExternalBinding::Ai(ExternalAiBinding {
354                provider: "openai".to_string(),
355                api_key: "sk-test".into(),
356            }),
357        );
358
359        assert!(bindings.has("llm"));
360        assert_eq!(bindings.get("llm").unwrap().binding_type(), "ai");
361        assert_eq!(bindings.get_ai("llm").unwrap().unwrap().provider, "openai");
362        assert!(bindings.get_kv("llm").is_err()); // Wrong type
363    }
364
365    #[test]
366    fn test_external_bindings_serialization() {
367        let mut bindings = ExternalBindings::new();
368        bindings.insert(
369            "data",
370            ExternalBinding::Storage(StorageBinding::s3("test-bucket")),
371        );
372
373        let json = serde_json::to_string(&bindings).unwrap();
374        let deserialized: ExternalBindings = serde_json::from_str(&json).unwrap();
375        assert_eq!(bindings, deserialized);
376    }
377}