Skip to main content

alien_core/bindings/
container.rs

1//! Container binding definitions for container-to-container communication
2//!
3//! This module defines the binding parameters for container resources:
4//! - Managed cloud containers (AWS/GCP/Azure - using internal DNS and optional public URL)
5//! - Local containers (Docker - using localhost URL)
6
7use super::BindingValue;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashMap};
10
11/// Represents a container binding for container-to-container or external communication
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "service", rename_all = "lowercase")]
14pub enum ContainerBinding {
15    /// Managed cloud container binding (AWS/GCP/Azure)
16    Horizon(HorizonContainerBinding),
17    /// Kubernetes container binding
18    Kubernetes(KubernetesContainerBinding),
19    /// Local Docker container binding
20    Local(LocalContainerBinding),
21}
22
23/// Managed cloud container binding configuration (for cloud platforms)
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct HorizonContainerBinding {
27    /// Container name in the managed container backend
28    pub container_name: BindingValue<String>,
29    /// Internal URL (e.g., "http://api.svc:8080")
30    pub internal_url: BindingValue<String>,
31    /// Optional public URL (if exposed publicly via load balancer)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub public_url: Option<BindingValue<String>>,
34    /// Internal URLs keyed by port, available to every linking resource.
35    #[serde(
36        default,
37        skip_serializing_if = "HashMap::is_empty",
38        serialize_with = "serialize_internal_urls",
39        deserialize_with = "deserialize_internal_urls"
40    )]
41    pub internal_urls: HashMap<u16, BindingValue<String>>,
42}
43
44/// Kubernetes container binding configuration
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct KubernetesContainerBinding {
48    /// The container name
49    pub name: BindingValue<String>,
50    /// The Kubernetes namespace
51    pub namespace: BindingValue<String>,
52    /// The Kubernetes Service name
53    pub service_name: BindingValue<String>,
54    /// The Service port
55    pub service_port: BindingValue<u16>,
56    /// Optional public URL if container is exposed publicly
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub public_url: Option<BindingValue<String>>,
59    /// Internal URLs keyed by port, available to every linking resource.
60    #[serde(
61        default,
62        skip_serializing_if = "HashMap::is_empty",
63        serialize_with = "serialize_internal_urls",
64        deserialize_with = "deserialize_internal_urls"
65    )]
66    pub internal_urls: HashMap<u16, BindingValue<String>>,
67}
68
69/// Local Docker container binding configuration
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct LocalContainerBinding {
73    /// Container name/ID
74    pub container_name: BindingValue<String>,
75    /// Internal URL (Docker network DNS, e.g., "http://api.svc:8080")
76    pub internal_url: BindingValue<String>,
77    /// Optional public URL (localhost with mapped port, e.g., "http://localhost:62844")
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub public_url: Option<BindingValue<String>>,
80    /// Internal URLs keyed by port, available to every linking resource.
81    #[serde(
82        default,
83        skip_serializing_if = "HashMap::is_empty",
84        serialize_with = "serialize_internal_urls",
85        deserialize_with = "deserialize_internal_urls"
86    )]
87    pub internal_urls: HashMap<u16, BindingValue<String>>,
88}
89
90// A binding is rendered into an environment variable, and Kubernetes rolls every pod when a pod
91// template's env changes. Sort by port so the same URLs always render the same JSON.
92fn serialize_internal_urls<S>(
93    urls: &HashMap<u16, BindingValue<String>>,
94    serializer: S,
95) -> Result<S::Ok, S::Error>
96where
97    S: serde::Serializer,
98{
99    urls.iter()
100        .collect::<BTreeMap<_, _>>()
101        .serialize(serializer)
102}
103
104// Internally tagged bindings buffer map keys as strings; parse ports explicitly.
105fn deserialize_internal_urls<'de, D>(
106    deserializer: D,
107) -> Result<HashMap<u16, BindingValue<String>>, D::Error>
108where
109    D: serde::Deserializer<'de>,
110{
111    HashMap::<String, BindingValue<String>>::deserialize(deserializer)?
112        .into_iter()
113        .map(|(port, url)| {
114            port.parse::<u16>()
115                .map(|port| (port, url))
116                .map_err(serde::de::Error::custom)
117        })
118        .collect()
119}
120
121impl ContainerBinding {
122    /// Creates a managed cloud container binding
123    pub fn horizon(
124        container_name: impl Into<BindingValue<String>>,
125        internal_url: impl Into<BindingValue<String>>,
126    ) -> Self {
127        Self::Horizon(HorizonContainerBinding {
128            container_name: container_name.into(),
129            internal_url: internal_url.into(),
130            public_url: None,
131            internal_urls: HashMap::new(),
132        })
133    }
134
135    /// Creates a managed cloud container binding with public URL
136    pub fn horizon_with_public_url(
137        container_name: impl Into<BindingValue<String>>,
138        internal_url: impl Into<BindingValue<String>>,
139        public_url: impl Into<BindingValue<String>>,
140    ) -> Self {
141        Self::Horizon(HorizonContainerBinding {
142            container_name: container_name.into(),
143            internal_url: internal_url.into(),
144            public_url: Some(public_url.into()),
145            internal_urls: HashMap::new(),
146        })
147    }
148
149    /// Creates a local Docker container binding
150    pub fn local(
151        container_name: impl Into<BindingValue<String>>,
152        internal_url: impl Into<BindingValue<String>>,
153    ) -> Self {
154        Self::Local(LocalContainerBinding {
155            container_name: container_name.into(),
156            internal_url: internal_url.into(),
157            public_url: None,
158            internal_urls: HashMap::new(),
159        })
160    }
161
162    /// Creates a local Docker container binding with public URL
163    pub fn local_with_public_url(
164        container_name: impl Into<BindingValue<String>>,
165        internal_url: impl Into<BindingValue<String>>,
166        public_url: impl Into<BindingValue<String>>,
167    ) -> Self {
168        Self::Local(LocalContainerBinding {
169            container_name: container_name.into(),
170            internal_url: internal_url.into(),
171            public_url: Some(public_url.into()),
172            internal_urls: HashMap::new(),
173        })
174    }
175
176    /// Creates a Kubernetes container binding
177    pub fn kubernetes(
178        name: impl Into<BindingValue<String>>,
179        namespace: impl Into<BindingValue<String>>,
180        service_name: impl Into<BindingValue<String>>,
181        service_port: impl Into<BindingValue<u16>>,
182    ) -> Self {
183        Self::Kubernetes(KubernetesContainerBinding {
184            name: name.into(),
185            namespace: namespace.into(),
186            service_name: service_name.into(),
187            service_port: service_port.into(),
188            public_url: None,
189            internal_urls: HashMap::new(),
190        })
191    }
192
193    /// Creates a Kubernetes container binding with public URL
194    pub fn kubernetes_with_public_url(
195        name: impl Into<BindingValue<String>>,
196        namespace: impl Into<BindingValue<String>>,
197        service_name: impl Into<BindingValue<String>>,
198        service_port: impl Into<BindingValue<u16>>,
199        public_url: impl Into<BindingValue<String>>,
200    ) -> Self {
201        Self::Kubernetes(KubernetesContainerBinding {
202            name: name.into(),
203            namespace: namespace.into(),
204            service_name: service_name.into(),
205            service_port: service_port.into(),
206            public_url: Some(public_url.into()),
207            internal_urls: HashMap::new(),
208        })
209    }
210
211    /// Internal URLs keyed by declared port.
212    pub fn internal_urls(&self) -> &HashMap<u16, BindingValue<String>> {
213        match self {
214            Self::Horizon(binding) => &binding.internal_urls,
215            Self::Kubernetes(binding) => &binding.internal_urls,
216            Self::Local(binding) => &binding.internal_urls,
217        }
218    }
219
220    /// Sets the internal URLs supplied by the backend.
221    pub fn with_internal_urls(mut self, endpoints: HashMap<u16, BindingValue<String>>) -> Self {
222        match &mut self {
223            Self::Horizon(binding) => binding.internal_urls = endpoints,
224            Self::Kubernetes(binding) => binding.internal_urls = endpoints,
225            Self::Local(binding) => binding.internal_urls = endpoints,
226        }
227        self
228    }
229
230    /// Gets the internal URL for any platform
231    /// For Kubernetes, constructs the cluster-local DNS name
232    pub fn get_internal_url(&self) -> Option<String> {
233        match self {
234            ContainerBinding::Horizon(binding) => {
235                // Extract value if it's a concrete value, not a template expression
236                if let BindingValue::Value(url) = &binding.internal_url {
237                    Some(url.clone())
238                } else {
239                    None
240                }
241            }
242            ContainerBinding::Kubernetes(binding) => {
243                // Construct cluster-local DNS name from components
244                if let (
245                    BindingValue::Value(service_name),
246                    BindingValue::Value(namespace),
247                    BindingValue::Value(port),
248                ) = (
249                    &binding.service_name,
250                    &binding.namespace,
251                    &binding.service_port,
252                ) {
253                    Some(format!(
254                        "http://{}.{}.svc.cluster.local:{}",
255                        service_name, namespace, port
256                    ))
257                } else {
258                    None
259                }
260            }
261            ContainerBinding::Local(binding) => {
262                if let BindingValue::Value(url) = &binding.internal_url {
263                    Some(url.clone())
264                } else {
265                    None
266                }
267            }
268        }
269    }
270
271    /// Gets the public URL if available for any platform
272    pub fn get_public_url(&self) -> Option<&BindingValue<String>> {
273        match self {
274            ContainerBinding::Horizon(binding) => binding.public_url.as_ref(),
275            ContainerBinding::Kubernetes(binding) => binding.public_url.as_ref(),
276            ContainerBinding::Local(binding) => binding.public_url.as_ref(),
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_horizon_binding() {
287        let binding = ContainerBinding::horizon("api", "http://api.svc:8080");
288
289        if let ContainerBinding::Horizon(horizon_binding) = binding {
290            assert_eq!(
291                horizon_binding.container_name,
292                BindingValue::Value("api".to_string())
293            );
294            assert_eq!(
295                horizon_binding.internal_url,
296                BindingValue::Value("http://api.svc:8080".to_string())
297            );
298            assert!(horizon_binding.public_url.is_none());
299        } else {
300            panic!("Expected Horizon binding");
301        }
302    }
303
304    #[test]
305    fn test_horizon_binding_with_public_url() {
306        let binding = ContainerBinding::horizon_with_public_url(
307            "api",
308            "http://api.svc:8080",
309            "https://api.example.com",
310        );
311
312        if let ContainerBinding::Horizon(horizon_binding) = binding {
313            assert_eq!(
314                horizon_binding.public_url,
315                Some(BindingValue::Value("https://api.example.com".to_string()))
316            );
317        } else {
318            panic!("Expected Horizon binding");
319        }
320    }
321
322    #[test]
323    fn test_local_binding() {
324        let binding = ContainerBinding::local("my-container", "http://my-container.svc:8080");
325
326        if let ContainerBinding::Local(local_binding) = binding {
327            assert_eq!(
328                local_binding.container_name,
329                BindingValue::Value("my-container".to_string())
330            );
331            assert_eq!(
332                local_binding.internal_url,
333                BindingValue::Value("http://my-container.svc:8080".to_string())
334            );
335            assert!(local_binding.public_url.is_none());
336        } else {
337            panic!("Expected Local binding");
338        }
339    }
340
341    #[test]
342    fn test_local_binding_with_public_url() {
343        let binding = ContainerBinding::local_with_public_url(
344            "my-container",
345            "http://my-container.svc:8080",
346            "http://localhost:62844",
347        );
348
349        if let ContainerBinding::Local(local_binding) = binding {
350            assert_eq!(
351                local_binding.public_url,
352                Some(BindingValue::Value("http://localhost:62844".to_string()))
353            );
354        } else {
355            panic!("Expected Local binding");
356        }
357    }
358
359    #[test]
360    fn test_get_internal_url() {
361        let horizon = ContainerBinding::horizon("api", "http://api.svc:8080");
362        assert_eq!(
363            horizon.get_internal_url(),
364            Some("http://api.svc:8080".to_string())
365        );
366
367        let local = ContainerBinding::local("api", "http://api.svc:3000");
368        assert_eq!(
369            local.get_internal_url(),
370            Some("http://api.svc:3000".to_string())
371        );
372    }
373
374    #[test]
375    fn test_serialization_roundtrip() {
376        let binding = ContainerBinding::horizon_with_public_url(
377            "api",
378            "http://api.svc:8080",
379            "https://api.example.com",
380        );
381
382        let serialized = serde_json::to_string(&binding).unwrap();
383        let deserialized: ContainerBinding = serde_json::from_str(&serialized).unwrap();
384        assert_eq!(binding, deserialized);
385    }
386}