Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 20 additions & 24 deletions api/src/app/core/cdktf/ranges/base_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DeployedRangeSchema,
)
from ....schemas.secret_schema import SecretSchema
from ....utils.cdktf_utils import gen_resource_logical_ids
from ....utils.name_utils import normalize_name
from ...config import settings
from ..stacks.base_stack import AbstractBaseStack
Expand All @@ -32,15 +33,6 @@
class AbstractBaseRange(ABC):
"""Abstract class to enforce common functionality across range cloud providers."""

name: str
range_obj: BlueprintRangeSchema | DeployedRangeSchema
state_file: dict[str, Any] | None # Terraform state
region: OpenLabsRegion
stack_name: str
secrets: SecretSchema
deployed_range_name: str
description: str

# Mutex for terraform init calls
_init_lock = asyncio.Lock()

Expand Down Expand Up @@ -422,47 +414,51 @@ async def _parse_terraform_outputs( # noqa: PLR0911
dumped_schema["jumpbox_public_ip"] = raw_outputs[jumpbox_ip_key]["value"]
dumped_schema["range_private_key"] = raw_outputs[private_key]["value"]

vpc_logical_ids = gen_resource_logical_ids(
[vpc.name for vpc in self.range_obj.vpcs]
)
for x, vpc in enumerate(self.range_obj.vpcs):

normalized_vpc_name = normalize_name(vpc.name)

vpc_logical_id = vpc_logical_ids[vpc.name]
current_vpc = dumped_schema["vpcs"][x]

vpc_key = next(
(
key
for key in raw_outputs
if key.endswith(f"-{normalized_vpc_name}-resource-id")
if key.endswith(f"-{vpc_logical_id}-resource-id")
),
None,
)
if not vpc_key:
logger.error(
"Could not find VPC resource ID key for %s in Terraform output",
normalized_vpc_name,
vpc_logical_id,
)
return None
current_vpc["resource_id"] = raw_outputs[vpc_key]["value"]

subnet_logical_ids = gen_resource_logical_ids(
[subnet.name for subnet in vpc.subnets] # type: ignore
)
for y, subnet in enumerate(vpc.subnets): # type: ignore

normalized_subnet_name = normalize_name(subnet.name)

subnet_logical_id = subnet_logical_ids[subnet.name]
current_subnet = current_vpc["subnets"][y]

subnet_key = next(
(
key
for key in raw_outputs
if key.endswith(
f"-{normalized_vpc_name}-{normalized_subnet_name}-resource-id"
f"-{vpc_logical_id}-{subnet_logical_id}-resource-id"
)
),
None,
)
if not subnet_key:
logger.error(
"Could not find subnet resource ID key for %s in %s in Terraform output",
normalized_subnet_name,
normalized_vpc_name,
subnet_logical_id,
vpc_logical_id,
)
return None
current_subnet["resource_id"] = raw_outputs[subnet_key]["value"]
Expand All @@ -474,7 +470,7 @@ async def _parse_terraform_outputs( # noqa: PLR0911
key
for key in raw_outputs
if key.endswith(
f"-{normalized_vpc_name}-{normalized_subnet_name}-{host.hostname}-resource-id"
f"-{vpc_logical_id}-{subnet_logical_id}-{host.hostname}-resource-id"
)
),
None,
Expand All @@ -484,7 +480,7 @@ async def _parse_terraform_outputs( # noqa: PLR0911
key
for key in raw_outputs
if key.endswith(
f"-{normalized_vpc_name}-{normalized_subnet_name}-{host.hostname}-private-ip"
f"-{vpc_logical_id}-{subnet_logical_id}-{host.hostname}-private-ip"
)
),
None,
Expand All @@ -494,8 +490,8 @@ async def _parse_terraform_outputs( # noqa: PLR0911
logger.error(
"Could not find host keys for %s in %s/%s in Terraform output",
host.hostname,
normalized_vpc_name,
normalized_subnet_name,
vpc_logical_id,
subnet_logical_id,
)
return None

Expand Down
54 changes: 28 additions & 26 deletions api/src/app/core/cdktf/stacks/aws_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
from ....enums.regions import AWS_REGION_MAP, OpenLabsRegion
from ....enums.specs import AWS_SPEC_MAP
from ....schemas.range_schemas import BlueprintRangeSchema, DeployedRangeSchema
from ....utils.cdktf_utils import gen_resource_logical_ids
from ....utils.crypto import generate_range_rsa_key_pair
from ....utils.name_utils import normalize_name
from .base_stack import AbstractBaseStack


Expand Down Expand Up @@ -267,23 +267,23 @@ def build_resources(
)

# Create Range vpcs, subnets, hosts
vpc_logical_ids = gen_resource_logical_ids([vpc.name for vpc in range_obj.vpcs])
for vpc in range_obj.vpcs:

normalized_vpc_name = normalize_name(vpc.name)
vpc_logical_id = vpc_logical_ids[vpc.name]

# Step 14: Create a VPC
new_vpc = Vpc(
self,
f"{range_name}-{normalized_vpc_name}",
f"{range_name}-{vpc_logical_id}",
cidr_block=str(vpc.cidr),
enable_dns_support=True,
enable_dns_hostnames=True,
tags={"Name": normalized_vpc_name},
tags={"Name": vpc_logical_id},
)

TerraformOutput(
self,
f"{range_name}-{normalized_vpc_name}-resource-id",
f"{range_name}-{vpc_logical_id}-resource-id",
value=new_vpc.id,
description="Cloud resource id of the vpc created",
sensitive=True,
Expand All @@ -294,13 +294,13 @@ def build_resources(
# Every VPC will use the same secrutiy group but security groups are scoped to a single VPC, so they have to be added to each one
private_vpc_sg = SecurityGroup(
self,
f"{range_name}-{normalized_vpc_name}-SharedPrivateSG",
f"{range_name}-{vpc_logical_id}-SharedPrivateSG",
vpc_id=new_vpc.id,
tags={"Name": "RangePrivateInternalSecurityGroup"},
)
SecurityGroupRule( # Allow access from the Jumpbox - possibly not needed based on next rule
self,
f"{range_name}-{normalized_vpc_name}-RangeAllowAllTrafficFromJumpBox-Rule",
f"{range_name}-{vpc_logical_id}-RangeAllowAllTrafficFromJumpBox-Rule",
type="ingress",
from_port=0,
to_port=0,
Expand All @@ -310,7 +310,7 @@ def build_resources(
)
SecurityGroupRule(
self,
f"{range_name}-{normalized_vpc_name}-RangeAllowInternalTraffic-Rule", # Allow all internal subnets to communicate with each other
f"{range_name}-{vpc_logical_id}-RangeAllowInternalTraffic-Rule", # Allow all internal subnets to communicate with each other
type="ingress",
from_port=0,
to_port=0,
Expand All @@ -320,7 +320,7 @@ def build_resources(
)
SecurityGroupRule(
self,
f"{range_name}-{normalized_vpc_name}-RangeAllowPrivateOutbound-Rule",
f"{range_name}-{vpc_logical_id}-RangeAllowPrivateOutbound-Rule",
type="egress",
from_port=0,
to_port=0,
Expand All @@ -330,23 +330,25 @@ def build_resources(
)

current_vpc_subnets: list[Subnet] = []
subnet_logical_ids = gen_resource_logical_ids(
[subnet.name for subnet in vpc.subnets]
)
# Step 16: Create private subnets with their respecitve EC2 instances
for subnet in vpc.subnets:

normalized_subnet_name = normalize_name(subnet.name)
subnet_logical_id = subnet_logical_ids[subnet.name]

new_subnet = Subnet(
self,
f"{range_name}-{normalized_vpc_name}-{normalized_subnet_name}",
f"{range_name}-{vpc_logical_id}-{subnet_logical_id}",
vpc_id=new_vpc.id,
cidr_block=str(subnet.cidr),
availability_zone="us-east-1a",
tags={"Name": normalized_subnet_name},
tags={"Name": subnet_logical_id},
)

TerraformOutput(
self,
f"{range_name}-{normalized_vpc_name}-{normalized_subnet_name}-resource-id",
f"{range_name}-{vpc_logical_id}-{subnet_logical_id}-resource-id",
value=new_subnet.id,
description="Cloud resource id of the subnet created",
sensitive=True,
Expand All @@ -358,7 +360,7 @@ def build_resources(
for host in subnet.hosts:
ec2_instance = Instance(
self,
f"{range_name}-{normalized_vpc_name}-{normalized_subnet_name}-{host.hostname}",
f"{range_name}-{vpc_logical_id}-{subnet_logical_id}-{host.hostname}",
ami=AWS_OS_MAP[host.os],
instance_type=AWS_SPEC_MAP[host.spec],
subnet_id=new_subnet.id,
Expand All @@ -369,14 +371,14 @@ def build_resources(

TerraformOutput(
self,
f"{range_name}-{normalized_vpc_name}-{normalized_subnet_name}-{host.hostname}-resource-id",
f"{range_name}-{vpc_logical_id}-{subnet_logical_id}-{host.hostname}-resource-id",
value=ec2_instance.id,
description="Cloud resource id of the ec2 instance created",
sensitive=True,
)
TerraformOutput(
self,
f"{range_name}-{normalized_vpc_name}-{normalized_subnet_name}-{host.hostname}-private-ip",
f"{range_name}-{vpc_logical_id}-{subnet_logical_id}-{host.hostname}-private-ip",
value=ec2_instance.private_ip,
description="Cloud private IP address of the ec2 instance created",
sensitive=True,
Expand All @@ -385,28 +387,28 @@ def build_resources(
# Step 17: Attach VPC to Transit Gateway
private_vpc_tgw_attachment = Ec2TransitGatewayVpcAttachment( # noqa: F841
self,
f"{range_name}-{normalized_vpc_name}-PrivateVpcTgwAttachment",
f"{range_name}-{vpc_logical_id}-PrivateVpcTgwAttachment",
subnet_ids=[
current_vpc_subnets[0].id
], # Attach TGW ENIs to all private subnets
transit_gateway_id=tgw.id,
vpc_id=new_vpc.id,
transit_gateway_default_route_table_association=True,
transit_gateway_default_route_table_propagation=True,
tags={"Name": f"{normalized_vpc_name}-private-vpc-tgw-attachment"},
tags={"Name": f"{vpc_logical_id}-private-vpc-tgw-attachment"},
)

# Step 18: Create Routing in range VPC (Routes to TGW to access other range VPCs or the internet via the NAT gateway)
new_vpc_private_route_table = RouteTable(
self,
f"{range_name}-{normalized_vpc_name}-PrivateRouteTable",
f"{range_name}-{vpc_logical_id}-PrivateRouteTable",
vpc_id=new_vpc.id,
tags={"Name": f"{normalized_vpc_name}-private-route-table"},
tags={"Name": f"{vpc_logical_id}-private-route-table"},
)
# Default route for range VPC to Transit Gateway
tgw_route = Route( # noqa: F841
self,
f"{range_name}-{normalized_vpc_name}-PrivateTgwRoute",
f"{range_name}-{vpc_logical_id}-PrivateTgwRoute",
route_table_id=new_vpc_private_route_table.id,
destination_cidr_block="0.0.0.0/0", # All traffic goes to TGW
transit_gateway_id=tgw.id,
Expand All @@ -415,7 +417,7 @@ def build_resources(
for i, created_subnet in enumerate(current_vpc_subnets):
RouteTableAssociation(
self,
f"{range_name}-{normalized_vpc_name}-PrivateSubnetRouteTableAssociation_{i+1}",
f"{range_name}-{vpc_logical_id}-PrivateSubnetRouteTableAssociation_{i+1}",
subnet_id=str(created_subnet.id),
route_table_id=new_vpc_private_route_table.id,
)
Expand All @@ -425,7 +427,7 @@ def build_resources(
# Add route to the Jumpbox VPC's Public route table (for Jumpbox access & NAT Return Traffic)
Route(
self,
f"{range_name}-{normalized_vpc_name}-PublicRtbToPrivateVpcRoute",
f"{range_name}-{vpc_logical_id}-PublicRtbToPrivateVpcRoute",
route_table_id=jumpbox_route_table.id, # Route in the public subnet's RT
destination_cidr_block=new_vpc.cidr_block, # Traffic destined to the range VPCs will go through the transit gateway
transit_gateway_id=tgw.id,
Expand All @@ -434,7 +436,7 @@ def build_resources(
# This ensures traffic arriving *from* the TGW destined for another private VPC goes back *to* the TGW
Route(
self,
f"{range_name}-{normalized_vpc_name}-PublicVpcTgwSubnetRtbToPrivateVpcRoute",
f"{range_name}-{vpc_logical_id}-PublicVpcTgwSubnetRtbToPrivateVpcRoute",
route_table_id=nat_route_table.id, # Route in the TGW attachment subnet's Route Table (jumpbox private subnet)
destination_cidr_block=new_vpc.cidr_block, # Traffic destined to the range VPCs will go through the transit gateway
transit_gateway_id=tgw.id,
Expand Down
56 changes: 56 additions & 0 deletions api/src/app/utils/cdktf_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,63 @@
import tempfile
from collections import Counter, defaultdict

from .name_utils import normalize_name


def create_cdktf_dir() -> str:
"""Create temp dir for CDKTF."""
# /tmp/.openlabs-cdktf-XXXX
return tempfile.mkdtemp(prefix=".openlabs-cdktf-")


def gen_resource_logical_ids(resource_names: list[str]) -> dict[str, str]:
"""Generate deterministic, normalized, and unique logical IDs from a list of resource names.

This function handles collisions that occur after normalization by appending
a numeric suffix.

Args:
resource_names: A list of user-supplied resource names.

Returns:
A dictionary mapping each original resource name to its unique logical ID.

Example:
>>> names = ["Web Server", "Database", "web-server", "Auth Service"]
>>> gen_resource_logical_ids(names)
{
'Auth Service': 'auth-service',
'Database': 'database',
'Web Server': 'web-server',
'web-server': 'web-server-1'
}

"""
if len(set(resource_names)) != len(resource_names):
counts = Counter(resource_names)
duplicates = [name for name, count in counts.items() if count > 1]
msg = f"Input list contains exact duplicate names: {', '.join(duplicates)}"
raise ValueError(msg)

logical_ids: dict[str, str] = {}
seen_counts: defaultdict[str, int] = defaultdict(int)

# Sorted to ensure deterministic ID generation
sorted_names = sorted(resource_names)

for name in sorted_names:
base_id = normalize_name(name)

# The first time we see a base_id, its ID is just itself
# each time we see it again we add a suffix
if seen_counts[base_id] > 0:
logical_id = f"{base_id}-{seen_counts[base_id]}"
else:
logical_id = base_id

logical_ids[name] = logical_id

# Increment the count for the next potential collision
seen_counts[base_id] += 1

return logical_ids
Loading
Loading