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            (
147                NetworkSettings::Create {
148                    availability_zones: old_zones,
149                    ..
150                },
151                NetworkSettings::Create {
152                    availability_zones: new_zones,
153                    ..
154                },
155            ) => {
156                // The network controller creates subnets once and never adds or removes zones.
157                if old_zones != new_zones {
158                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
159                        resource_id: self.id.clone(),
160                        reason: format!(
161                            "the network's availability zone count can't change after it is created ({old_zones} to {new_zones}); create a new deployment to use a different count"
162                        ),
163                    }));
164                }
165                Ok(())
166            }
167            (NetworkSettings::ByoVpcAws { .. }, NetworkSettings::ByoVpcAws { .. }) => Ok(()),
168            (NetworkSettings::ByoVpcGcp { .. }, NetworkSettings::ByoVpcGcp { .. }) => Ok(()),
169            (NetworkSettings::ByoVnetAzure { .. }, NetworkSettings::ByoVnetAzure { .. }) => Ok(()),
170            _ => Err(AlienError::new(ErrorData::InvalidResourceUpdate {
171                resource_id: self.id.clone(),
172                reason: "cannot change network type (e.g., from 'create' to 'use-default')"
173                    .to_string(),
174            })),
175        }
176    }
177
178    fn as_any(&self) -> &dyn Any {
179        self
180    }
181
182    fn as_any_mut(&mut self) -> &mut dyn Any {
183        self
184    }
185
186    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
187        Box::new(self.clone())
188    }
189
190    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
191        other.as_any().downcast_ref::<Network>() == Some(self)
192    }
193
194    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
195        serde_json::to_value(self)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_network_create_settings() {
205        let network = Network::new("default-network".to_string())
206            .settings(NetworkSettings::Create {
207                cidr: Some("10.0.0.0/16".to_string()),
208                availability_zones: 3,
209            })
210            .build();
211
212        assert_eq!(network.id(), "default-network");
213        match &network.settings {
214            NetworkSettings::Create {
215                cidr,
216                availability_zones,
217            } => {
218                assert_eq!(cidr.as_deref(), Some("10.0.0.0/16"));
219                assert_eq!(*availability_zones, 3);
220            }
221            _ => panic!("Expected Create settings"),
222        }
223    }
224
225    #[test]
226    fn test_network_byo_vpc_aws_settings() {
227        let network = Network::new("default-network".to_string())
228            .settings(NetworkSettings::ByoVpcAws {
229                vpc_id: "vpc-12345".to_string(),
230                public_subnet_ids: vec!["subnet-pub-1".to_string(), "subnet-pub-2".to_string()],
231                private_subnet_ids: vec!["subnet-priv-1".to_string(), "subnet-priv-2".to_string()],
232                security_group_ids: vec!["sg-123".to_string()],
233            })
234            .build();
235
236        match &network.settings {
237            NetworkSettings::ByoVpcAws {
238                vpc_id,
239                public_subnet_ids,
240                private_subnet_ids,
241                security_group_ids,
242            } => {
243                assert_eq!(vpc_id, "vpc-12345");
244                assert_eq!(public_subnet_ids.len(), 2);
245                assert_eq!(private_subnet_ids.len(), 2);
246                assert_eq!(security_group_ids.len(), 1);
247            }
248            _ => panic!("Expected ByoVpcAws settings"),
249        }
250    }
251
252    #[test]
253    fn test_network_validate_update_same_type() {
254        let network = 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        assert!(network.validate_update(&network.clone()).is_ok());
262    }
263
264    #[test]
265    fn test_network_validate_update_availability_zone_change_fails() {
266        let network1 = Network::new("default-network".to_string())
267            .settings(NetworkSettings::Create {
268                cidr: Some("10.0.0.0/16".to_string()),
269                availability_zones: 2,
270            })
271            .build();
272
273        let network2 = Network::new("default-network".to_string())
274            .settings(NetworkSettings::Create {
275                cidr: Some("10.0.0.0/16".to_string()),
276                availability_zones: 3,
277            })
278            .build();
279
280        let error = network1
281            .validate_update(&network2)
282            .expect_err("changing the zone count should be rejected");
283        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
284        assert!(error.message.contains("(2 to 3)"), "{}", error.message);
285    }
286
287    #[test]
288    fn test_network_validate_update_different_type_fails() {
289        let network1 = Network::new("default-network".to_string())
290            .settings(NetworkSettings::Create {
291                cidr: Some("10.0.0.0/16".to_string()),
292                availability_zones: 2,
293            })
294            .build();
295
296        let network2 = Network::new("default-network".to_string())
297            .settings(NetworkSettings::ByoVpcAws {
298                vpc_id: "vpc-12345".to_string(),
299                public_subnet_ids: vec![],
300                private_subnet_ids: vec![],
301                security_group_ids: vec![],
302            })
303            .build();
304
305        // Changing network type should fail
306        let result = network1.validate_update(&network2);
307        assert!(result.is_err());
308    }
309
310    #[test]
311    fn test_network_validate_update_use_default() {
312        let network1 = Network::new("default-network".to_string())
313            .settings(NetworkSettings::UseDefault)
314            .build();
315
316        let network2 = Network::new("default-network".to_string())
317            .settings(NetworkSettings::UseDefault)
318            .build();
319
320        assert!(network1.validate_update(&network2).is_ok());
321    }
322
323    #[test]
324    fn test_network_validate_update_use_default_to_create_fails() {
325        let network1 = Network::new("default-network".to_string())
326            .settings(NetworkSettings::UseDefault)
327            .build();
328
329        let network2 = Network::new("default-network".to_string())
330            .settings(NetworkSettings::Create {
331                cidr: None,
332                availability_zones: 2,
333            })
334            .build();
335
336        assert!(network1.validate_update(&network2).is_err());
337    }
338
339    #[test]
340    fn test_network_serialization() {
341        let network = Network::new("default-network".to_string())
342            .settings(NetworkSettings::Create {
343                cidr: None,
344                availability_zones: 2,
345            })
346            .build();
347
348        let json = serde_json::to_string(&network).unwrap();
349        let deserialized: Network = serde_json::from_str(&json).unwrap();
350        assert_eq!(network, deserialized);
351    }
352}