Skip to content

Commit c8e324c

Browse files
authored
Fix resources created in apply in aws-py-hub-and-spoke-network (#2678)
## Summary - Refactor spoke and inspection VPCs to stop creating resources inside `apply()` callbacks, which caused those resources to not appear in `pulumi preview` - Replace `get_subnets_output` data source lookups with direct VPC component outputs (`isolated_subnet_ids`, `public_subnet_ids`) so resources appear in preview even on fresh stacks - Use `_output` variants of data source functions (`get_route_table_output`, `get_subnet_output`) to work with `Output[str]` values - Update `requirements.txt` to latest major versions, fix AWSX subnet strategy warnings, remove unused imports 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 4ca61f3 commit c8e324c

4 files changed

Lines changed: 98 additions & 113 deletions

File tree

aws-py-hub-and-spoke-network/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ A hub-and-spoke network is a common architecture for creating a network topology
2525

2626
### Step 1: Initialize the Project
2727

28-
For Pulumi examples, we typically start by creating a directory and changing into it. Then, we create a new Pulumi project from a template. For example, `azure-javascript`.
28+
For Pulumi examples, we typically start by creating a directory and changing into it. Then, we create a new Pulumi project from a template. For example, `aws-python`.
2929

3030
1. Install packages:
3131

aws-py-hub-and-spoke-network/inspection.py

Lines changed: 67 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import pulumi_aws as aws
66
import pulumi_awsx as awsx
77

8-
from pprint import pprint
8+
9+
def _find_endpoint_for_az(az: str, statuses) -> str:
10+
for sync_state in statuses[0]["sync_states"]:
11+
if sync_state["availability_zone"] == az:
12+
return sync_state["attachments"][0]["endpoint_id"]
13+
raise Exception(f"No firewall endpoint found for AZ '{az}'")
914

1015

1116
@dataclass
@@ -25,7 +30,6 @@ def __init__(
2530
) -> None:
2631
super().__init__("awsAdvancedNetworking:index:InspectionVpc", name, None, opts)
2732

28-
# So we can reference later in our apply handler:
2933
self.name = name
3034
self.args = args
3135

@@ -51,6 +55,7 @@ def __init__(
5155
nat_gateways=awsx.ec2.NatGatewayConfigurationArgs(
5256
strategy=awsx.ec2.NatGatewayStrategy.NONE
5357
),
58+
subnet_strategy=awsx.ec2.SubnetAllocationStrategy.AUTO,
5459
),
5560
opts=pulumi.ResourceOptions(
5661
*(opts or {}),
@@ -124,17 +129,35 @@ def __init__(
124129
pulumi.ResourceOptions(parent=self),
125130
)
126131

132+
# We know that there are 3 AZs in our VPC because that is the default
133+
# for the AWSX VPC component and we don't specify an argument and we
134+
# also do not allow the input to be configurable in this component.
135+
#
136+
# Because we know there are 3 AZs, we can extract the 3 subnet IDs into
137+
# a list of known length, which in turn allows us to avoid creating
138+
# resources in an apply(), which is bad practice because the pulumi
139+
# preview output would not necessarily match the pulumi up behavior.
140+
#
141+
# If we did not know the length of the list in advance, we would have to
142+
# call apply on the list of unknown length and then loop through its
143+
# values to create the necessary routes.
144+
public_subnet_ids = [
145+
self.vpc.public_subnet_ids.apply(lambda x: x[0]),
146+
self.vpc.public_subnet_ids.apply(lambda x: x[1]),
147+
self.vpc.public_subnet_ids.apply(lambda x: x[2]),
148+
]
149+
150+
isolated_subnet_ids = [
151+
self.vpc.isolated_subnet_ids.apply(lambda x: x[0]),
152+
self.vpc.isolated_subnet_ids.apply(lambda x: x[1]),
153+
self.vpc.isolated_subnet_ids.apply(lambda x: x[2]),
154+
]
155+
127156
if args.firewall_policy_arn:
128157
self.create_firewall()
129-
pulumi.Output.all(
130-
self.firewall.firewall_statuses,
131-
self.vpc.public_subnet_ids,
132-
self.vpc.isolated_subnet_ids,
133-
).apply(lambda args: self.create_firewall_routes(args[0], args[1], args[2]))
158+
self.create_firewall_routes(public_subnet_ids, isolated_subnet_ids)
134159
else:
135-
pulumi.Output.all(self.vpc.public_subnet_ids, self.vpc.isolated_subnet_ids).apply(
136-
lambda args: self.create_direct_nat_routes(args[0], args[1])
137-
)
160+
self.create_direct_nat_routes(public_subnet_ids, isolated_subnet_ids)
138161

139162
self.register_outputs(
140163
{
@@ -145,16 +168,18 @@ def __init__(
145168
)
146169

147170
def create_direct_nat_routes(
148-
self, public_subnet_ids: Sequence[str], isolated_subnet_ids: Sequence[str]
171+
self,
172+
public_subnet_ids: Sequence[pulumi.Input[str]],
173+
isolated_subnet_ids: Sequence[pulumi.Input[str]],
149174
):
150175
# Create routes for the supernet (a CIDR block that encompasses all
151176
# spoke VPCs) from the public subnets in the inspection VPC (where the
152177
# NAT Gateways for centralized egress live) to the TGW.
153-
for subnet_id in public_subnet_ids:
154-
route_table = aws.ec2.get_route_table(subnet_id=subnet_id)
178+
for i, subnet_id in enumerate(public_subnet_ids):
179+
route_table = aws.ec2.get_route_table_output(subnet_id=subnet_id)
155180

156181
aws.ec2.Route(
157-
f"{self.name}-route-{subnet_id}-to-tgw",
182+
f"{self.name}-public-route-{i}-to-tgw",
158183
aws.ec2.RouteArgs(
159184
route_table_id=route_table.id,
160185
destination_cidr_block=self.args.supernet_cidr_block,
@@ -168,11 +193,11 @@ def create_direct_nat_routes(
168193
)
169194

170195
# Create routes from the TGW subnet to the NAT Gateway.
171-
for subnet_id in isolated_subnet_ids:
172-
route_table = aws.ec2.get_route_table(subnet_id=subnet_id)
196+
for i, subnet_id in enumerate(isolated_subnet_ids):
197+
route_table = aws.ec2.get_route_table_output(subnet_id=subnet_id)
173198

174199
aws.ec2.Route(
175-
f"{self.name}-route-{subnet_id}-to-tgw",
200+
f"{self.name}-isolated-route-{i}-to-nat",
176201
aws.ec2.RouteArgs(
177202
route_table_id=route_table.id,
178203
destination_cidr_block="0.0.0.0/0",
@@ -273,40 +298,27 @@ def create_firewall(self):
273298
),
274299
)
275300

276-
def create_firewall_routes(self, statuses, public_subnet_ids, tgw_subnet_ids):
277-
# Map the output of the Firewall attachments to a structure more
278-
# suitable structure:
279-
attachments = []
280-
for sync_state in statuses[0]["sync_states"]:
281-
attachment = {
282-
"az": sync_state["availability_zone"],
283-
"subnet_id": sync_state["attachments"][0]["subnet_id"],
284-
"endpoint_id": sync_state["attachments"][0]["endpoint_id"],
285-
}
286-
attachments.append(attachment)
287-
301+
def create_firewall_routes(
302+
self,
303+
public_subnet_ids: Sequence[pulumi.Input[str]],
304+
tgw_subnet_ids: Sequence[pulumi.Input[str]],
305+
):
288306
# Add routes from public subnets to the firewall for incoming packets.
289-
for subnet_id in public_subnet_ids:
290-
subnet = aws.ec2.get_subnet(id=subnet_id)
291-
route_table = aws.ec2.get_route_table(subnet_id=subnet_id)
292-
293-
# Find the attachment in our availability zone
294-
subnet_attachments = [
295-
attachment
296-
for attachment in attachments
297-
if attachment["az"] == subnet.availability_zone
298-
]
299-
if len(subnet_attachments) != 1:
300-
raise Exception(
301-
f"Expected exactly 1 firewall subnet attachment for AZ '{subnet.availability_zone}'. Found {len(subnet_attachments)} instead."
302-
)
307+
for i, subnet_id in enumerate(public_subnet_ids):
308+
subnet = aws.ec2.get_subnet_output(id=subnet_id)
309+
route_table = aws.ec2.get_route_table_output(subnet_id=subnet_id)
310+
311+
endpoint_id = pulumi.Output.all(
312+
subnet.availability_zone,
313+
self.firewall.firewall_statuses,
314+
).apply(lambda args: _find_endpoint_for_az(args[0], args[1]))
303315

304316
aws.ec2.Route(
305-
f"{self.name}-{subnet_id}-to-firewall",
317+
f"{self.name}-public-{i}-to-firewall",
306318
aws.ec2.RouteArgs(
307319
route_table_id=route_table.id,
308320
destination_cidr_block=self.args.supernet_cidr_block,
309-
vpc_endpoint_id=subnet_attachments[0]["endpoint_id"],
321+
vpc_endpoint_id=endpoint_id,
310322
),
311323
pulumi.ResourceOptions(
312324
parent=self,
@@ -315,27 +327,21 @@ def create_firewall_routes(self, statuses, public_subnet_ids, tgw_subnet_ids):
315327
)
316328

317329
# Add routes from the TGW subnets to the firewall for outgoing packets.
318-
for subnet_id in tgw_subnet_ids:
319-
subnet = aws.ec2.get_subnet(id=subnet_id)
320-
route_table = aws.ec2.get_route_table(subnet_id=subnet_id)
321-
322-
# Find the attachment in our availability zone
323-
subnet_attachments = [
324-
attachment
325-
for attachment in attachments
326-
if attachment["az"] == subnet.availability_zone
327-
]
328-
if len(subnet_attachments) != 1:
329-
raise Exception(
330-
f"Expected exactly 1 firewall subnet attachment for AZ '{subnet.availability_zone}'. Found {len(subnet_attachments)} instead."
331-
)
330+
for i, subnet_id in enumerate(tgw_subnet_ids):
331+
subnet = aws.ec2.get_subnet_output(id=subnet_id)
332+
route_table = aws.ec2.get_route_table_output(subnet_id=subnet_id)
333+
334+
endpoint_id = pulumi.Output.all(
335+
subnet.availability_zone,
336+
self.firewall.firewall_statuses,
337+
).apply(lambda args: _find_endpoint_for_az(args[0], args[1]))
332338

333339
aws.ec2.Route(
334-
f"{self.name}-{subnet_id}-to-firewall",
340+
f"{self.name}-tgw-{i}-to-firewall",
335341
aws.ec2.RouteArgs(
336342
route_table_id=route_table.id,
337343
destination_cidr_block="0.0.0.0/0",
338-
vpc_endpoint_id=subnet_attachments[0]["endpoint_id"],
344+
vpc_endpoint_id=endpoint_id,
339345
),
340346
pulumi.ResourceOptions(
341347
parent=self,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
pulumi>=3.0.0,<4.0.0
2-
pulumi_aws>=7.23.0,<7.24.0
2+
pulumi_aws>=7.0.0,<8.0.0
33
pulumi_awsx>=2.0.0,<3.0.0

aws-py-hub-and-spoke-network/spoke.py

Lines changed: 29 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from dataclasses import dataclass
22
from typing import Sequence
33

4-
import json
5-
64
import pulumi
75
import pulumi_aws as aws
86
import pulumi_awsx as awsx
@@ -53,43 +51,33 @@ def __init__(self, name: str, args: SpokeVpcArgs, opts: pulumi.ResourceOptions =
5351
),
5452
enable_dns_hostnames=True,
5553
enable_dns_support=True,
54+
subnet_strategy=awsx.ec2.SubnetAllocationStrategy.AUTO,
5655
),
5756
pulumi.ResourceOptions(
5857
parent=self,
5958
),
6059
)
6160

62-
tgw_subnets = aws.ec2.get_subnets_output(
63-
filters=[
64-
aws.ec2.GetSubnetFilterArgs(
65-
name="tag:Name",
66-
values=[f"{name}-vpc-tgw-*"],
67-
),
68-
aws.ec2.GetSubnetFilterArgs(
69-
name="vpc-id",
70-
values=[self.vpc.vpc_id],
71-
),
72-
]
73-
)
74-
75-
tgw_subnets = aws.ec2.get_subnets_output(
76-
filters=[
77-
aws.ec2.GetSubnetFilterArgs(
78-
name="tag:Name",
79-
values=[f"{self._name}-vpc-tgw-*"],
80-
),
81-
aws.ec2.GetSubnetFilterArgs(
82-
name="vpc-id",
83-
values=[self.vpc.vpc_id],
84-
),
85-
]
86-
)
61+
# The AWSX VPC creates subnets in the order of subnet_specs, with
62+
# one subnet per AZ for each spec. Since we have 3 AZs (the default)
63+
# and two specs ("private" then "tgw"), isolated_subnet_ids contains
64+
# 6 IDs: indices 0-2 are "private" subnets, indices 3-5 are "tgw".
65+
#
66+
# We extract them into lists of known length so that we can create
67+
# resources without using apply(), which is bad practice because the
68+
# pulumi preview output would not necessarily match the pulumi up
69+
# behavior.
70+
tgw_subnet_ids = [
71+
self.vpc.isolated_subnet_ids.apply(lambda x: x[3]),
72+
self.vpc.isolated_subnet_ids.apply(lambda x: x[4]),
73+
self.vpc.isolated_subnet_ids.apply(lambda x: x[5]),
74+
]
8775

8876
self.tgw_attachment = aws.ec2transitgateway.VpcAttachment(
8977
f"{name}-tgw-vpc-attachment",
9078
aws.ec2transitgateway.VpcAttachmentArgs(
9179
transit_gateway_id=args.tgw_id,
92-
subnet_ids=tgw_subnets.apply(lambda x: x.ids),
80+
subnet_ids=tgw_subnet_ids,
9381
vpc_id=self.vpc.vpc_id,
9482
transit_gateway_default_route_table_association=False,
9583
transit_gateway_default_route_table_propagation=False,
@@ -128,26 +116,17 @@ def __init__(self, name: str, args: SpokeVpcArgs, opts: pulumi.ResourceOptions =
128116
),
129117
)
130118

131-
# Using get_subnets rather than vpc.isolated_subnet_ids because it's more
132-
# stable (in case we change the subnet type above) and descriptive:
133-
private_subnets = aws.ec2.get_subnets_output(
134-
filters=[
135-
aws.ec2.GetSubnetFilterArgs(
136-
name="tag:Name",
137-
values=[f"{self._name}-vpc-private-*"],
138-
),
139-
aws.ec2.GetSubnetFilterArgs(
140-
name="vpc-id",
141-
values=[self.vpc.vpc_id],
142-
),
143-
]
144-
)
145-
self.workload_subnet_ids = private_subnets.ids
119+
private_subnet_ids = [
120+
self.vpc.isolated_subnet_ids.apply(lambda x: x[0]),
121+
self.vpc.isolated_subnet_ids.apply(lambda x: x[1]),
122+
self.vpc.isolated_subnet_ids.apply(lambda x: x[2]),
123+
]
124+
self.workload_subnet_ids = private_subnet_ids
146125

147-
private_subnets.apply(lambda x: self._create_vpc_endpoints(x.ids))
148-
private_subnets.apply(lambda x: self._create_routes(x.ids))
126+
self._create_vpc_endpoints(private_subnet_ids)
127+
self._create_routes(private_subnet_ids)
149128

150-
def _create_vpc_endpoints(self, subnet_ids: Sequence[str]):
129+
def _create_vpc_endpoints(self, subnet_ids: Sequence[pulumi.Input[str]]):
151130
vpc_endpoint_sg = aws.ec2.SecurityGroup(
152131
f"{self._name}-vpc-endpoint-sg",
153132
aws.ec2.SecurityGroupArgs(
@@ -196,17 +175,17 @@ def _create_vpc_endpoints(self, subnet_ids: Sequence[str]):
196175

197176
def _create_routes(
198177
self,
199-
private_subnet_ids: Sequence[str],
178+
private_subnet_ids: Sequence[pulumi.Input[str]],
200179
):
201180

202-
for subnet_id in private_subnet_ids:
203-
route_table = aws.ec2.get_route_table(
181+
for i, subnet_id in enumerate(private_subnet_ids):
182+
route_table = aws.ec2.get_route_table_output(
204183
subnet_id=subnet_id,
205184
)
206185

207186
# Direct egress for anything outside this VPC to the Transit Gateway:
208187
aws.ec2.Route(
209-
f"spoke{self._name}-tgw-route-{subnet_id}",
188+
f"spoke{self._name}-tgw-route-{i}",
210189
aws.ec2.RouteArgs(
211190
route_table_id=route_table.id,
212191
destination_cidr_block="0.0.0.0/0",

0 commit comments

Comments
 (0)