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/// The flow log on a VPC that Alien creates on AWS. The CloudFormation and Terraform network
18/// emitters and the direct-setup network controller all build their flow log from this one
19/// definition, and a parity test compares the rendered templates with it.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct AwsVpcFlowLogSettings {
22    /// Accepted and rejected traffic.
23    pub traffic_type: &'static str,
24    pub log_destination_type: &'static str,
25    /// Seconds a flow is aggregated into one record. 600 is the AWS default and the cheapest.
26    pub max_aggregation_interval: u32,
27    /// Days the log group keeps records. Compliance checks expect one year of log retention.
28    pub retention_in_days: i32,
29    /// The service that assumes the delivery role.
30    pub delivery_service: &'static str,
31    /// What the delivery role may do on its own log group, and nothing else.
32    pub delivery_actions: &'static [&'static str],
33}
34
35pub const AWS_VPC_FLOW_LOG: AwsVpcFlowLogSettings = AwsVpcFlowLogSettings {
36    traffic_type: "ALL",
37    log_destination_type: "cloud-watch-logs",
38    max_aggregation_interval: 600,
39    retention_in_days: 365,
40    delivery_service: "vpc-flow-logs.amazonaws.com",
41    delivery_actions: &[
42        "logs:CreateLogStream",
43        "logs:PutLogEvents",
44        "logs:DescribeLogGroups",
45        "logs:DescribeLogStreams",
46    ],
47};
48
49/// Represents cloud-agnostic networking infrastructure (VPC, VNet, subnets, etc.).
50///
51/// This resource is **always auto-generated** by the NetworkMutation preflight based on
52/// `StackSettings.network`. Users don't define Network resources directly in `alien.ts`.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56#[builder(start_fn = new)]
57pub struct Network {
58    /// Unique identifier for the network resource (typically "default-network")
59    #[builder(start_fn)]
60    pub id: String,
61
62    /// Network configuration (copied from StackSettings)
63    pub settings: NetworkSettings,
64}
65
66impl Network {
67    /// The resource type identifier for Network
68    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("network");
69
70    /// Returns the network's unique identifier.
71    pub fn id(&self) -> &str {
72        &self.id
73    }
74
75    /// Settings for generating setup from a stack whose `StackSettings.network` is empty.
76    ///
77    /// The NetworkMutation preflight then adds a create-new network for the resources that need
78    /// one. Setup builds that network, so it must offer the same inputs as when create-new is
79    /// passed as the settings. A network declared any other way (the default VPC, an existing VPC)
80    /// stays fixed, so this returns `None` for it.
81    pub fn preflight_settings(stack: &Stack) -> Option<NetworkSettings> {
82        stack
83            .resources()
84            .find_map(|(_, entry)| entry.config.downcast_ref::<Network>())
85            .filter(|network| matches!(network.settings, NetworkSettings::Create { .. }))
86            .map(|network| network.settings.clone())
87    }
88}
89
90/// Outputs generated by a successfully provisioned Network.
91///
92/// Network outputs are **cloud-agnostic** and primarily for observability/debugging.
93/// Resources that need platform-specific details (VPC ID, subnet IDs) use `require_dependency`
94/// to access the controller state directly.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
97#[serde(rename_all = "camelCase")]
98pub struct NetworkOutputs {
99    /// Human-readable network identifier (VPC ID, VNet name, etc.)
100    pub network_id: String,
101
102    /// Number of availability zones / regions used
103    pub availability_zones: u8,
104
105    /// Whether public subnets exist
106    pub has_public_subnets: bool,
107
108    /// Whether NAT gateway exists
109    pub has_nat_gateway: bool,
110
111    /// Public addresses used by the deployment's NAT. Empty when unknown, including
112    /// bring-your-own networks whose egress is managed outside this deployment.
113    #[serde(default)]
114    pub egress_public_ips: Vec<String>,
115
116    /// CIDR block (if created by Alien, None for BYO-VPC)
117    pub cidr: Option<String>,
118}
119
120impl ResourceOutputsDefinition for NetworkOutputs {
121    fn get_resource_type(&self) -> ResourceType {
122        Network::RESOURCE_TYPE.clone()
123    }
124
125    fn as_any(&self) -> &dyn Any {
126        self
127    }
128
129    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
130        Box::new(self.clone())
131    }
132
133    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
134        other.as_any().downcast_ref::<NetworkOutputs>() == Some(self)
135    }
136
137    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
138        serde_json::to_value(self)
139    }
140}
141
142impl ResourceDefinition for Network {
143    fn get_resource_type(&self) -> ResourceType {
144        Self::RESOURCE_TYPE
145    }
146
147    fn id(&self) -> &str {
148        &self.id
149    }
150
151    fn get_dependencies(&self) -> Vec<ResourceRef> {
152        Vec::new() // Network has no dependencies
153    }
154
155    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
156        let new_network = new_config
157            .as_any()
158            .downcast_ref::<Network>()
159            .ok_or_else(|| {
160                AlienError::new(ErrorData::UnexpectedResourceType {
161                    resource_id: self.id.clone(),
162                    expected: Self::RESOURCE_TYPE,
163                    actual: new_config.get_resource_type(),
164                })
165            })?;
166
167        if self.id != new_network.id {
168            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
169                resource_id: self.id.clone(),
170                reason: "the 'id' field is immutable".to_string(),
171            }));
172        }
173
174        // Validate that network type doesn't change (e.g., from Create to BYO-VPC)
175        // This would require recreating all networking infrastructure
176        match (&self.settings, &new_network.settings) {
177            (NetworkSettings::UseDefault, NetworkSettings::UseDefault) => Ok(()),
178            (
179                NetworkSettings::Create {
180                    availability_zones: old_zones,
181                    ..
182                },
183                NetworkSettings::Create {
184                    availability_zones: new_zones,
185                    ..
186                },
187            ) => {
188                // The network controller creates subnets once and never adds or removes zones.
189                if old_zones != new_zones {
190                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
191                        resource_id: self.id.clone(),
192                        reason: format!(
193                            "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"
194                        ),
195                    }));
196                }
197                Ok(())
198            }
199            (NetworkSettings::ByoVpcAws { .. }, NetworkSettings::ByoVpcAws { .. }) => Ok(()),
200            (NetworkSettings::ByoVpcGcp { .. }, NetworkSettings::ByoVpcGcp { .. }) => Ok(()),
201            (NetworkSettings::ByoVnetAzure { .. }, NetworkSettings::ByoVnetAzure { .. }) => Ok(()),
202            _ => Err(AlienError::new(ErrorData::InvalidResourceUpdate {
203                resource_id: self.id.clone(),
204                reason: "cannot change network type (e.g., from 'create' to 'use-default')"
205                    .to_string(),
206            })),
207        }
208    }
209
210    fn as_any(&self) -> &dyn Any {
211        self
212    }
213
214    fn as_any_mut(&mut self) -> &mut dyn Any {
215        self
216    }
217
218    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
219        Box::new(self.clone())
220    }
221
222    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
223        other.as_any().downcast_ref::<Network>() == Some(self)
224    }
225
226    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
227        serde_json::to_value(self)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_network_create_settings() {
237        let network = Network::new("default-network".to_string())
238            .settings(NetworkSettings::Create {
239                cidr: Some("10.0.0.0/16".to_string()),
240                availability_zones: 3,
241            })
242            .build();
243
244        assert_eq!(network.id(), "default-network");
245        match &network.settings {
246            NetworkSettings::Create {
247                cidr,
248                availability_zones,
249            } => {
250                assert_eq!(cidr.as_deref(), Some("10.0.0.0/16"));
251                assert_eq!(*availability_zones, 3);
252            }
253            _ => panic!("Expected Create settings"),
254        }
255    }
256
257    #[test]
258    fn test_network_byo_vpc_aws_settings() {
259        let network = Network::new("default-network".to_string())
260            .settings(NetworkSettings::ByoVpcAws {
261                vpc_id: "vpc-12345".to_string(),
262                public_subnet_ids: vec!["subnet-pub-1".to_string(), "subnet-pub-2".to_string()],
263                private_subnet_ids: vec!["subnet-priv-1".to_string(), "subnet-priv-2".to_string()],
264                security_group_ids: vec!["sg-123".to_string()],
265            })
266            .build();
267
268        match &network.settings {
269            NetworkSettings::ByoVpcAws {
270                vpc_id,
271                public_subnet_ids,
272                private_subnet_ids,
273                security_group_ids,
274            } => {
275                assert_eq!(vpc_id, "vpc-12345");
276                assert_eq!(public_subnet_ids.len(), 2);
277                assert_eq!(private_subnet_ids.len(), 2);
278                assert_eq!(security_group_ids.len(), 1);
279            }
280            _ => panic!("Expected ByoVpcAws settings"),
281        }
282    }
283
284    #[test]
285    fn test_network_validate_update_same_type() {
286        let network = Network::new("default-network".to_string())
287            .settings(NetworkSettings::Create {
288                cidr: Some("10.0.0.0/16".to_string()),
289                availability_zones: 2,
290            })
291            .build();
292
293        assert!(network.validate_update(&network.clone()).is_ok());
294    }
295
296    #[test]
297    fn test_network_validate_update_availability_zone_change_fails() {
298        let network1 = Network::new("default-network".to_string())
299            .settings(NetworkSettings::Create {
300                cidr: Some("10.0.0.0/16".to_string()),
301                availability_zones: 2,
302            })
303            .build();
304
305        let network2 = Network::new("default-network".to_string())
306            .settings(NetworkSettings::Create {
307                cidr: Some("10.0.0.0/16".to_string()),
308                availability_zones: 3,
309            })
310            .build();
311
312        let error = network1
313            .validate_update(&network2)
314            .expect_err("changing the zone count should be rejected");
315        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
316        assert!(error.message.contains("(2 to 3)"), "{}", error.message);
317    }
318
319    #[test]
320    fn test_network_validate_update_different_type_fails() {
321        let network1 = Network::new("default-network".to_string())
322            .settings(NetworkSettings::Create {
323                cidr: Some("10.0.0.0/16".to_string()),
324                availability_zones: 2,
325            })
326            .build();
327
328        let network2 = Network::new("default-network".to_string())
329            .settings(NetworkSettings::ByoVpcAws {
330                vpc_id: "vpc-12345".to_string(),
331                public_subnet_ids: vec![],
332                private_subnet_ids: vec![],
333                security_group_ids: vec![],
334            })
335            .build();
336
337        // Changing network type should fail
338        let result = network1.validate_update(&network2);
339        assert!(result.is_err());
340    }
341
342    #[test]
343    fn test_network_validate_update_use_default() {
344        let network1 = Network::new("default-network".to_string())
345            .settings(NetworkSettings::UseDefault)
346            .build();
347
348        let network2 = Network::new("default-network".to_string())
349            .settings(NetworkSettings::UseDefault)
350            .build();
351
352        assert!(network1.validate_update(&network2).is_ok());
353    }
354
355    #[test]
356    fn test_network_validate_update_use_default_to_create_fails() {
357        let network1 = Network::new("default-network".to_string())
358            .settings(NetworkSettings::UseDefault)
359            .build();
360
361        let network2 = Network::new("default-network".to_string())
362            .settings(NetworkSettings::Create {
363                cidr: None,
364                availability_zones: 2,
365            })
366            .build();
367
368        assert!(network1.validate_update(&network2).is_err());
369    }
370
371    #[test]
372    fn test_network_serialization() {
373        let network = Network::new("default-network".to_string())
374            .settings(NetworkSettings::Create {
375                cidr: None,
376                availability_zones: 2,
377            })
378            .build();
379
380        let json = serde_json::to_string(&network).unwrap();
381        let deserialized: Network = serde_json::from_str(&json).unwrap();
382        assert_eq!(network, deserialized);
383    }
384}