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 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 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}