Skip to main content

rusticity_core/
vpc.rs

1use crate::config::AwsConfig;
2use anyhow::Result;
3use aws_sdk_ec2::types::{Filter, NetworkAcl, RouteTable, Vpc as SdkVpc};
4
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub struct Vpc {
7    pub name: String,
8    pub vpc_id: String,
9    pub state: String,
10    pub encryption_control_id: String,
11    pub encryption_control_mode: String,
12    pub block_public_access: String,
13    pub main_route_table: String,
14    pub main_network_acl: String,
15    pub ipv4_cidrs: String,
16    pub ipv6_cidrs: String,
17    pub is_default: Option<bool>,
18    pub tenancy: String,
19    pub dhcp_options_id: String,
20    pub owner_id: String,
21    pub region: String,
22    pub arn: String,
23}
24
25pub struct VpcClient {
26    config: AwsConfig,
27}
28
29impl VpcClient {
30    pub fn new(config: AwsConfig) -> Self {
31        Self { config }
32    }
33
34    /// Read every DescribeVpcs page; never mutate VPCs or their attributes.
35    pub async fn list_vpcs(&self) -> Result<Vec<Vpc>> {
36        let mut stream = self
37            .config
38            .ec2_client()
39            .describe_vpcs()
40            .into_paginator()
41            .items()
42            .send();
43        let partition = self
44            .config
45            .role_arn
46            .strip_prefix("arn:")
47            .and_then(|arn| arn.split(':').next())
48            .filter(|p| !p.is_empty())
49            .unwrap_or_else(|| partition_for_region(&self.config.region));
50        let mut vpcs = Vec::new();
51        while let Some(vpc) = stream.next().await {
52            vpcs.push(Vpc::from_sdk(&vpc?, &self.config.region, partition));
53        }
54        vpcs.sort_by(|a, b| a.vpc_id.cmp(&b.vpc_id));
55        Ok(vpcs)
56    }
57
58    /// Load the properties shown by the VPC console. The two related inventories
59    /// are optional: denied reads must not turn a real VPC into an empty list.
60    pub async fn list_vpcs_with_properties(&self) -> Result<Vec<Vpc>> {
61        let mut vpcs = self.list_vpcs().await?;
62        if vpcs.is_empty() {
63            return Ok(vpcs);
64        }
65        let client = self.config.ec2_client();
66        let routes = client
67            .describe_route_tables()
68            .filters(
69                Filter::builder()
70                    .name("association.main")
71                    .values("true")
72                    .build(),
73            )
74            .into_paginator()
75            .items()
76            .send()
77            .collect::<Result<Vec<_>, _>>();
78        let acls = client
79            .describe_network_acls()
80            .filters(Filter::builder().name("default").values("true").build())
81            .into_paginator()
82            .items()
83            .send()
84            .collect::<Result<Vec<_>, _>>();
85        let (routes, acls) = tokio::join!(routes, acls);
86        match routes {
87            Ok(routes) => apply_main_route_tables(&mut vpcs, &routes),
88            Err(error) => {
89                tracing::warn!("VPC main route tables unavailable: {error}");
90                for vpc in &mut vpcs {
91                    vpc.main_route_table = "Unavailable".into();
92                }
93            }
94        }
95        match acls {
96            Ok(acls) => apply_main_network_acls(&mut vpcs, &acls),
97            Err(error) => {
98                tracing::warn!("VPC main network ACLs unavailable: {error}");
99                for vpc in &mut vpcs {
100                    vpc.main_network_acl = "Unavailable".into();
101                }
102            }
103        }
104        Ok(vpcs)
105    }
106}
107
108fn apply_main_route_tables(vpcs: &mut [Vpc], routes: &[RouteTable]) {
109    for vpc in vpcs {
110        vpc.main_route_table = routes
111            .iter()
112            .find(|route| {
113                route.vpc_id() == Some(vpc.vpc_id.as_str())
114                    && route.associations().iter().any(|a| a.main() == Some(true))
115            })
116            .and_then(|route| route.route_table_id())
117            .unwrap_or_default()
118            .into();
119    }
120}
121
122fn apply_main_network_acls(vpcs: &mut [Vpc], acls: &[NetworkAcl]) {
123    for vpc in vpcs {
124        vpc.main_network_acl = acls
125            .iter()
126            .find(|acl| acl.vpc_id() == Some(vpc.vpc_id.as_str()) && acl.is_default() == Some(true))
127            .and_then(|acl| acl.network_acl_id())
128            .unwrap_or_default()
129            .into();
130    }
131}
132
133fn partition_for_region(region: &str) -> &'static str {
134    if region.starts_with("cn-") {
135        "aws-cn"
136    } else if region.starts_with("us-gov-") {
137        "aws-us-gov"
138    } else if region.starts_with("us-isob-") {
139        "aws-iso-b"
140    } else if region.starts_with("us-iso-") {
141        "aws-iso"
142    } else if region.starts_with("eu-isoe-") {
143        "aws-iso-e"
144    } else if region.starts_with("us-isof-") {
145        "aws-iso-f"
146    } else {
147        "aws"
148    }
149}
150
151fn cidr_label(cidr: Option<&str>, state: Option<&str>) -> Option<String> {
152    let cidr = cidr.filter(|cidr| !cidr.is_empty())?;
153    match state {
154        Some("disassociated") => None,
155        None | Some("associated") => Some(cidr.into()),
156        Some(state) => Some(format!("{cidr} ({state})")),
157    }
158}
159
160impl Vpc {
161    fn from_sdk(vpc: &SdkVpc, region: &str, partition: &str) -> Self {
162        let vpc_id = vpc.vpc_id().unwrap_or_default().to_string();
163        let owner_id = vpc.owner_id().unwrap_or_default().to_string();
164        let mut ipv4 = Vec::new();
165        if let Some(cidr) = cidr_label(vpc.cidr_block(), None) {
166            ipv4.push(cidr);
167        }
168        for association in vpc.cidr_block_association_set() {
169            if let Some(cidr) = cidr_label(
170                association.cidr_block(),
171                association
172                    .cidr_block_state()
173                    .and_then(|s| s.state())
174                    .map(|s| s.as_str()),
175            ) {
176                if !ipv4.contains(&cidr) {
177                    ipv4.push(cidr);
178                }
179            }
180        }
181        let mut ipv6 = Vec::new();
182        for association in vpc.ipv6_cidr_block_association_set() {
183            if let Some(cidr) = cidr_label(
184                association.ipv6_cidr_block(),
185                association
186                    .ipv6_cidr_block_state()
187                    .and_then(|s| s.state())
188                    .map(|s| s.as_str()),
189            ) {
190                if !ipv6.contains(&cidr) {
191                    ipv6.push(cidr);
192                }
193            }
194        }
195        let arn = if vpc_id.is_empty() || owner_id.is_empty() || region.is_empty() {
196            String::new()
197        } else {
198            format!("arn:{partition}:ec2:{region}:{owner_id}:vpc/{vpc_id}")
199        };
200        Self {
201            name: vpc
202                .tags()
203                .iter()
204                .find(|t| t.key() == Some("Name"))
205                .and_then(|t| t.value())
206                .unwrap_or_default()
207                .into(),
208            vpc_id,
209            owner_id,
210            arn,
211            region: region.into(),
212            state: vpc.state().map(|s| s.as_str().into()).unwrap_or_default(),
213            encryption_control_id: vpc
214                .encryption_control()
215                .and_then(|c| c.vpc_encryption_control_id())
216                .unwrap_or_default()
217                .into(),
218            encryption_control_mode: vpc
219                .encryption_control()
220                .and_then(|c| c.mode())
221                .map(|m| m.as_str())
222                .unwrap_or_default()
223                .into(),
224            block_public_access: vpc
225                .block_public_access_states()
226                .and_then(|b| b.internet_gateway_block_mode())
227                .map(|m| m.as_str())
228                .unwrap_or_default()
229                .into(),
230            ipv4_cidrs: ipv4.join(", "),
231            ipv6_cidrs: ipv6.join(", "),
232            is_default: vpc.is_default(),
233            tenancy: vpc
234                .instance_tenancy()
235                .map(|t| t.as_str().into())
236                .unwrap_or_default(),
237            dhcp_options_id: vpc.dhcp_options_id().unwrap_or_default().into(),
238            ..Default::default()
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use aws_sdk_ec2::types::{
247        Tag, Tenancy, VpcCidrBlockAssociation, VpcCidrBlockState, VpcCidrBlockStateCode,
248        VpcIpv6CidrBlockAssociation, VpcState,
249    };
250
251    #[test]
252    fn vpc_metadata_keeps_primary_secondary_and_active_ipv6_cidrs() {
253        let sdk = SdkVpc::builder()
254            .vpc_id("vpc-test")
255            .owner_id("123456789012")
256            .tags(Tag::builder().key("Name").value("test-network").build())
257            .state(VpcState::Available)
258            .cidr_block("10.0.0.0/16")
259            .is_default(false)
260            .instance_tenancy(Tenancy::Default)
261            .dhcp_options_id("dopt-test")
262            .cidr_block_association_set(
263                VpcCidrBlockAssociation::builder()
264                    .cidr_block("10.0.0.0/16")
265                    .cidr_block_state(
266                        VpcCidrBlockState::builder()
267                            .state(VpcCidrBlockStateCode::Associated)
268                            .build(),
269                    )
270                    .build(),
271            )
272            .cidr_block_association_set(
273                VpcCidrBlockAssociation::builder()
274                    .cidr_block("10.1.0.0/16")
275                    .build(),
276            )
277            .ipv6_cidr_block_association_set(
278                VpcIpv6CidrBlockAssociation::builder()
279                    .ipv6_cidr_block("2001:db8::/56")
280                    .ipv6_cidr_block_state(
281                        VpcCidrBlockState::builder()
282                            .state(VpcCidrBlockStateCode::Associated)
283                            .build(),
284                    )
285                    .build(),
286            )
287            .ipv6_cidr_block_association_set(
288                VpcIpv6CidrBlockAssociation::builder()
289                    .ipv6_cidr_block("2001:db8:100::/56")
290                    .ipv6_cidr_block_state(
291                        VpcCidrBlockState::builder()
292                            .state(VpcCidrBlockStateCode::Disassociated)
293                            .build(),
294                    )
295                    .build(),
296            )
297            .build();
298        let vpc = Vpc::from_sdk(&sdk, "us-east-2", "aws");
299        assert_eq!(vpc.name, "test-network");
300        assert_eq!(vpc.state, "available");
301        assert_eq!(vpc.ipv4_cidrs, "10.0.0.0/16, 10.1.0.0/16");
302        assert_eq!(vpc.ipv6_cidrs, "2001:db8::/56");
303        assert_eq!(vpc.is_default, Some(false));
304        assert_eq!(vpc.tenancy, "default");
305        assert_eq!(vpc.dhcp_options_id, "dopt-test");
306        assert_eq!(vpc.arn, "arn:aws:ec2:us-east-2:123456789012:vpc/vpc-test");
307    }
308
309    #[test]
310    fn vpc_missing_fields_and_pending_associations_are_not_fabricated() {
311        let vpc = Vpc::from_sdk(&SdkVpc::builder().build(), "us-east-2", "aws");
312        assert!(vpc.name.is_empty() && vpc.arn.is_empty() && vpc.ipv4_cidrs.is_empty());
313        assert_eq!(vpc.is_default, None);
314        assert_eq!(
315            cidr_label(Some("10.2.0.0/16"), Some("associating")),
316            Some("10.2.0.0/16 (associating)".into())
317        );
318    }
319
320    #[test]
321    fn vpc_arn_uses_owner_and_partition() {
322        let sdk = SdkVpc::builder()
323            .vpc_id("vpc-test")
324            .owner_id("123456789012")
325            .build();
326        let vpc = Vpc::from_sdk(&sdk, "cn-north-1", "aws-cn");
327        assert_eq!(
328            vpc.arn,
329            "arn:aws-cn:ec2:cn-north-1:123456789012:vpc/vpc-test"
330        );
331        assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
332        assert_eq!(partition_for_region("us-isob-east-1"), "aws-iso-b");
333    }
334}
335
336#[cfg(test)]
337mod property_tests {
338    use super::*;
339    use aws_sdk_ec2::types::{
340        BlockPublicAccessMode, BlockPublicAccessStates, RouteTableAssociation,
341        VpcEncryptionControl, VpcEncryptionControlMode,
342    };
343
344    #[test]
345    fn vpc_maps_encryption_control_and_block_public_access_without_defaults() {
346        let sdk = SdkVpc::builder()
347            .encryption_control(
348                VpcEncryptionControl::builder()
349                    .vpc_encryption_control_id("vpc-enc-test")
350                    .mode(VpcEncryptionControlMode::Monitor)
351                    .build(),
352            )
353            .block_public_access_states(
354                BlockPublicAccessStates::builder()
355                    .internet_gateway_block_mode(BlockPublicAccessMode::BlockBidirectional)
356                    .build(),
357            )
358            .build();
359        let vpc = Vpc::from_sdk(&sdk, "us-east-2", "aws");
360        assert_eq!(vpc.encryption_control_id, "vpc-enc-test");
361        assert_eq!(vpc.encryption_control_mode, "monitor");
362        assert_eq!(vpc.block_public_access, "block-bidirectional");
363        let missing = Vpc::from_sdk(&SdkVpc::builder().build(), "us-east-2", "aws");
364        assert!(missing.encryption_control_id.is_empty());
365        assert!(missing.encryption_control_mode.is_empty());
366        assert!(missing.block_public_access.is_empty());
367    }
368
369    #[test]
370    fn vpc_related_properties_join_by_vpc_and_require_main_or_default() {
371        let mut vpcs = vec![
372            Vpc {
373                vpc_id: "vpc-a".into(),
374                ..Default::default()
375            },
376            Vpc {
377                vpc_id: "vpc-b".into(),
378                ..Default::default()
379            },
380            Vpc {
381                vpc_id: "vpc-missing".into(),
382                ..Default::default()
383            },
384        ];
385        let routes = [
386            RouteTable::builder()
387                .route_table_id("rtb-custom")
388                .vpc_id("vpc-a")
389                .associations(RouteTableAssociation::builder().main(false).build())
390                .build(),
391            RouteTable::builder()
392                .route_table_id("rtb-b")
393                .vpc_id("vpc-b")
394                .associations(RouteTableAssociation::builder().main(true).build())
395                .build(),
396            RouteTable::builder()
397                .route_table_id("rtb-a")
398                .vpc_id("vpc-a")
399                .associations(RouteTableAssociation::builder().main(true).build())
400                .build(),
401        ];
402        let acls = [
403            NetworkAcl::builder()
404                .network_acl_id("acl-custom")
405                .vpc_id("vpc-a")
406                .is_default(false)
407                .build(),
408            NetworkAcl::builder()
409                .network_acl_id("acl-b")
410                .vpc_id("vpc-b")
411                .is_default(true)
412                .build(),
413            NetworkAcl::builder()
414                .network_acl_id("acl-a")
415                .vpc_id("vpc-a")
416                .is_default(true)
417                .build(),
418        ];
419        apply_main_route_tables(&mut vpcs, &routes);
420        apply_main_network_acls(&mut vpcs, &acls);
421        assert_eq!(vpcs[0].main_route_table, "rtb-a");
422        assert_eq!(vpcs[0].main_network_acl, "acl-a");
423        assert_eq!(vpcs[1].main_route_table, "rtb-b");
424        assert_eq!(vpcs[1].main_network_acl, "acl-b");
425        assert!(vpcs[2].main_route_table.is_empty());
426        assert!(vpcs[2].main_network_acl.is_empty());
427    }
428}