Skip to main content

alien_core/resources/
network.rs

1//! Network resource for cloud-agnostic VPC/VNet infrastructure.
2//!
3//! The Network resource provides cloud-agnostic networking infrastructure that can be
4//! shared across multiple resource types. Users configure network settings in StackSettings,
5//! and the NetworkMutation preflight auto-generates the Network resource.
6
7use crate::error::{ErrorData, Result};
8use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
9use crate::stack_settings::NetworkSettings;
10use crate::{ResourceType, Stack};
11use alien_error::AlienError;
12use bon::Builder;
13use serde::{Deserialize, Serialize};
14use std::any::Any;
15use std::fmt::Debug;
16
17/// Represents cloud-agnostic networking infrastructure (VPC, VNet, subnets, etc.).
18///
19/// This resource is **always auto-generated** by the NetworkMutation preflight based on
20/// `StackSettings.network`. Users don't define Network resources directly in `alien.ts`.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24#[builder(start_fn = new)]
25pub struct Network {
26    /// Unique identifier for the network resource (typically "default-network")
27    #[builder(start_fn)]
28    pub id: String,
29
30    /// Network configuration (copied from StackSettings)
31    pub settings: NetworkSettings,
32}
33
34impl Network {
35    /// The resource type identifier for Network
36    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("network");
37
38    /// Returns the network's unique identifier.
39    pub fn id(&self) -> &str {
40        &self.id
41    }
42
43    /// Settings for generating setup from a stack whose `StackSettings.network` is empty.
44    ///
45    /// The NetworkMutation preflight then adds a create-new network for the resources that need
46    /// one. Setup builds that network, so it must offer the same inputs as when create-new is
47    /// passed as the settings. A network declared any other way (the default VPC, an existing VPC)
48    /// stays fixed, so this returns `None` for it.
49    pub fn preflight_settings(stack: &Stack) -> Option<NetworkSettings> {
50        stack
51            .resources()
52            .find_map(|(_, entry)| entry.config.downcast_ref::<Network>())
53            .filter(|network| matches!(network.settings, NetworkSettings::Create { .. }))
54            .map(|network| network.settings.clone())
55    }
56}
57
58/// Outputs generated by a successfully provisioned Network.
59///
60/// Network outputs are **cloud-agnostic** and primarily for observability/debugging.
61/// Resources that need platform-specific details (VPC ID, subnet IDs) use `require_dependency`
62/// to access the controller state directly.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
65#[serde(rename_all = "camelCase")]
66pub struct NetworkOutputs {
67    /// Human-readable network identifier (VPC ID, VNet name, etc.)
68    pub network_id: String,
69
70    /// Number of availability zones / regions used
71    pub availability_zones: u8,
72
73    /// Whether public subnets exist
74    pub has_public_subnets: bool,
75
76    /// Whether NAT gateway exists
77    pub has_nat_gateway: bool,
78
79    /// Public addresses used by the deployment's NAT. Empty when unknown, including
80    /// bring-your-own networks whose egress is managed outside this deployment.
81    #[serde(default)]
82    pub egress_public_ips: Vec<String>,
83
84    /// CIDR block (if created by Alien, None for BYO-VPC)
85    pub cidr: Option<String>,
86}
87
88impl ResourceOutputsDefinition for NetworkOutputs {
89    fn get_resource_type(&self) -> ResourceType {
90        Network::RESOURCE_TYPE.clone()
91    }
92
93    fn as_any(&self) -> &dyn Any {
94        self
95    }
96
97    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
98        Box::new(self.clone())
99    }
100
101    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
102        other.as_any().downcast_ref::<NetworkOutputs>() == Some(self)
103    }
104
105    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
106        serde_json::to_value(self)
107    }
108}
109
110impl ResourceDefinition for Network {
111    fn get_resource_type(&self) -> ResourceType {
112        Self::RESOURCE_TYPE
113    }
114
115    fn id(&self) -> &str {
116        &self.id
117    }
118
119    fn get_dependencies(&self) -> Vec<ResourceRef> {
120        Vec::new() // Network has no dependencies
121    }
122
123    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
124        let new_network = new_config
125            .as_any()
126            .downcast_ref::<Network>()
127            .ok_or_else(|| {
128                AlienError::new(ErrorData::UnexpectedResourceType {
129                    resource_id: self.id.clone(),
130                    expected: Self::RESOURCE_TYPE,
131                    actual: new_config.get_resource_type(),
132                })
133            })?;
134
135        if self.id != new_network.id {
136            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
137                resource_id: self.id.clone(),
138                reason: "the 'id' field is immutable".to_string(),
139            }));
140        }
141
142        // Validate that network type doesn't change (e.g., from Create to BYO-VPC)
143        // This would require recreating all networking infrastructure
144        match (&self.settings, &new_network.settings) {
145            (NetworkSettings::UseDefault, NetworkSettings::UseDefault) => Ok(()),
146            (NetworkSettings::Create { .. }, NetworkSettings::Create { .. }) => Ok(()),
147            (NetworkSettings::ByoVpcAws { .. }, NetworkSettings::ByoVpcAws { .. }) => Ok(()),
148            (NetworkSettings::ByoVpcGcp { .. }, NetworkSettings::ByoVpcGcp { .. }) => Ok(()),
149            (NetworkSettings::ByoVnetAzure { .. }, NetworkSettings::ByoVnetAzure { .. }) => Ok(()),
150            _ => Err(AlienError::new(ErrorData::InvalidResourceUpdate {
151                resource_id: self.id.clone(),
152                reason: "cannot change network type (e.g., from 'create' to 'use-default')"
153                    .to_string(),
154            })),
155        }
156    }
157
158    fn as_any(&self) -> &dyn Any {
159        self
160    }
161
162    fn as_any_mut(&mut self) -> &mut dyn Any {
163        self
164    }
165
166    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
167        Box::new(self.clone())
168    }
169
170    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
171        other.as_any().downcast_ref::<Network>() == Some(self)
172    }
173
174    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
175        serde_json::to_value(self)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_network_create_settings() {
185        let network = Network::new("default-network".to_string())
186            .settings(NetworkSettings::Create {
187                cidr: Some("10.0.0.0/16".to_string()),
188                availability_zones: 3,
189            })
190            .build();
191
192        assert_eq!(network.id(), "default-network");
193        match &network.settings {
194            NetworkSettings::Create {
195                cidr,
196                availability_zones,
197            } => {
198                assert_eq!(cidr.as_deref(), Some("10.0.0.0/16"));
199                assert_eq!(*availability_zones, 3);
200            }
201            _ => panic!("Expected Create settings"),
202        }
203    }
204
205    #[test]
206    fn test_network_byo_vpc_aws_settings() {
207        let network = Network::new("default-network".to_string())
208            .settings(NetworkSettings::ByoVpcAws {
209                vpc_id: "vpc-12345".to_string(),
210                public_subnet_ids: vec!["subnet-pub-1".to_string(), "subnet-pub-2".to_string()],
211                private_subnet_ids: vec!["subnet-priv-1".to_string(), "subnet-priv-2".to_string()],
212                security_group_ids: vec!["sg-123".to_string()],
213            })
214            .build();
215
216        match &network.settings {
217            NetworkSettings::ByoVpcAws {
218                vpc_id,
219                public_subnet_ids,
220                private_subnet_ids,
221                security_group_ids,
222            } => {
223                assert_eq!(vpc_id, "vpc-12345");
224                assert_eq!(public_subnet_ids.len(), 2);
225                assert_eq!(private_subnet_ids.len(), 2);
226                assert_eq!(security_group_ids.len(), 1);
227            }
228            _ => panic!("Expected ByoVpcAws settings"),
229        }
230    }
231
232    #[test]
233    fn test_network_validate_update_same_type() {
234        let network1 = Network::new("default-network".to_string())
235            .settings(NetworkSettings::Create {
236                cidr: Some("10.0.0.0/16".to_string()),
237                availability_zones: 2,
238            })
239            .build();
240
241        let network2 = Network::new("default-network".to_string())
242            .settings(NetworkSettings::Create {
243                cidr: Some("10.0.0.0/16".to_string()),
244                availability_zones: 3, // Changed
245            })
246            .build();
247
248        // Same network type (Create) should be allowed
249        assert!(network1.validate_update(&network2).is_ok());
250    }
251
252    #[test]
253    fn test_network_validate_update_different_type_fails() {
254        let network1 = Network::new("default-network".to_string())
255            .settings(NetworkSettings::Create {
256                cidr: Some("10.0.0.0/16".to_string()),
257                availability_zones: 2,
258            })
259            .build();
260
261        let network2 = Network::new("default-network".to_string())
262            .settings(NetworkSettings::ByoVpcAws {
263                vpc_id: "vpc-12345".to_string(),
264                public_subnet_ids: vec![],
265                private_subnet_ids: vec![],
266                security_group_ids: vec![],
267            })
268            .build();
269
270        // Changing network type should fail
271        let result = network1.validate_update(&network2);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_network_validate_update_use_default() {
277        let network1 = Network::new("default-network".to_string())
278            .settings(NetworkSettings::UseDefault)
279            .build();
280
281        let network2 = Network::new("default-network".to_string())
282            .settings(NetworkSettings::UseDefault)
283            .build();
284
285        assert!(network1.validate_update(&network2).is_ok());
286    }
287
288    #[test]
289    fn test_network_validate_update_use_default_to_create_fails() {
290        let network1 = Network::new("default-network".to_string())
291            .settings(NetworkSettings::UseDefault)
292            .build();
293
294        let network2 = Network::new("default-network".to_string())
295            .settings(NetworkSettings::Create {
296                cidr: None,
297                availability_zones: 2,
298            })
299            .build();
300
301        assert!(network1.validate_update(&network2).is_err());
302    }
303
304    #[test]
305    fn test_network_serialization() {
306        let network = Network::new("default-network".to_string())
307            .settings(NetworkSettings::Create {
308                cidr: None,
309                availability_zones: 2,
310            })
311            .build();
312
313        let json = serde_json::to_string(&network).unwrap();
314        let deserialized: Network = serde_json::from_str(&json).unwrap();
315        assert_eq!(network, deserialized);
316    }
317}