diff --git a/.github/workflows/pulumi_staging.yaml b/.github/workflows/pulumi_staging.yaml index a3845b7..214161e 100644 --- a/.github/workflows/pulumi_staging.yaml +++ b/.github/workflows/pulumi_staging.yaml @@ -22,6 +22,7 @@ env: #GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEPLOY_ENVIRONMENT: staging AWS_ROLE_TO_ASSUME: ${{ secrets.STAGING_GITHUB_ACTIONS_AWS_ROLE_ARN }} + PULUMI_CLOUD_URL_STAGING: ${{ vars.PULUMI_CLOUD_URL_STAGING }} # the s3 backend url: s3://xxx jobs: ## ============================================================================ ## PR Comment Trigger Handler @@ -286,7 +287,7 @@ jobs: run: | cd ${{ env.tf_working_dir }} export PULUMI_CONFIG_PASSPHRASE="" - pulumi login ${{ vars.PULUMI_CLOUD_URL_STAGING }} + pulumi login ${{ env.PULUMI_CLOUD_URL_STAGING }} # Check if stack exists, create it if it doesn't STACK_NAME="organization/${{ steps.getStackName.outputs.result }}/${{ env.DEPLOY_ENVIRONMENT }}" @@ -366,7 +367,7 @@ jobs: uses: pulumi/actions@v6 with: always-include-summary: true - cloud-url: ${{ vars.PULUMI_CLOUD_URL_STAGING }} + cloud-url: ${{ env.PULUMI_CLOUD_URL_STAGING }} command: preview comment-on-pr: true comment-on-summary: true @@ -392,7 +393,7 @@ jobs: run: | cd ${{ env.tf_working_dir }} export PULUMI_CONFIG_PASSPHRASE="" - pulumi login ${{ vars.PULUMI_CLOUD_URL_STAGING }} + pulumi login ${{ env.PULUMI_CLOUD_URL_STAGING }} # Ensure stack exists before running up STACK_NAME="organization/${{ steps.getStackName.outputs.result }}/${{ env.DEPLOY_ENVIRONMENT }}" @@ -414,7 +415,7 @@ jobs: ) with: always-include-summary: true - cloud-url: ${{ vars.PULUMI_CLOUD_URL_STAGING }} + cloud-url: ${{ env.PULUMI_CLOUD_URL_STAGING }} command: up comment-on-pr: true comment-on-summary: true diff --git a/pulumi/components/aws/vpc/PulumiPlugin.yaml b/pulumi/components/aws/vpc/PulumiPlugin.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/pulumi/components/aws/vpc/PulumiPlugin.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/pulumi/components/aws/vpc/README.md b/pulumi/components/aws/vpc/README.md new file mode 100644 index 0000000..7ac3e5f --- /dev/null +++ b/pulumi/components/aws/vpc/README.md @@ -0,0 +1,221 @@ +# VPC Component + +A reusable Pulumi component for creating AWS VPC infrastructure with public and private subnets. + +## Features + +- **VPC**: Creates a VPC with configurable CIDR block and DNS support +- **Public Subnets**: Configurable number of public subnets (2-3 typical) across availability zones with auto-assign public IP +- **Private Subnets**: Configurable number of private subnets (2-3 typical) across availability zones +- **Internet Gateway**: For public subnet internet connectivity +- **Route Tables**: Properly configured route tables for public and private subnets +- **Default Security Group**: Allows all inbound and outbound traffic (configurable) +- **Optional NAT Gateways**: One NAT Gateway per availability zone for private subnet internet access +- **Flexible Subnet Count**: Supports 2 or 3 subnets (or more) - simply provide the desired number of CIDR blocks + +## Usage + +### In Pulumi YAML + +```yaml +name: my-vpc +runtime: yaml +packages: + vpc: https://github.com/ManagedKube/devops-with-ai.git/pulumi/components/aws/vpc@0.0.1 + +resources: + my-vpc: + type: vpc:index:Vpc + properties: + vpcCidr: "10.0.0.0/16" + publicSubnetCidrs: + - "10.0.1.0/24" + - "10.0.2.0/24" + - "10.0.3.0/24" + privateSubnetCidrs: + - "10.0.11.0/24" + - "10.0.12.0/24" + - "10.0.13.0/24" + availabilityZones: + - "us-west-2a" + - "us-west-2b" + - "us-west-2c" + enableNatGateway: true + vpcName: "my-application-vpc" + tagsAdditional: + Environment: "production" + ManagedBy: "pulumi" +``` + +### In Python + +```python +from vpc import Vpc + +vpc = Vpc( + "my-vpc", + { + "vpcCidr": "10.0.0.0/16", + "publicSubnetCidrs": [ + "10.0.1.0/24", + "10.0.2.0/24", + "10.0.3.0/24", + ], + "privateSubnetCidrs": [ + "10.0.11.0/24", + "10.0.12.0/24", + "10.0.13.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "enableNatGateway": True, + "vpcName": "my-application-vpc", + "tagsAdditional": { + "Environment": "production", + "ManagedBy": "pulumi", + }, + }, +) + +# Access outputs +vpc_id = vpc.vpc_id +public_subnet_ids = vpc.public_subnet_ids +private_subnet_ids = vpc.private_subnet_ids +``` + +### Example with 2 Subnets + +You can create a VPC with just 2 subnets by providing 2 CIDR blocks: + +```yaml +resources: + my-vpc-two-subnets: + type: vpc:index:Vpc + properties: + vpcCidr: "10.0.0.0/16" + publicSubnetCidrs: + - "10.0.1.0/24" + - "10.0.2.0/24" + privateSubnetCidrs: + - "10.0.11.0/24" + - "10.0.12.0/24" + availabilityZones: + - "us-east-1a" + - "us-east-1b" + enableNatGateway: false + vpcName: "my-two-subnet-vpc" + tagsAdditional: + Environment: "development" +``` + +## Parameters + +### Required Parameters + +- **vpcCidr** (string): CIDR block for the VPC (e.g., "10.0.0.0/16") +- **publicSubnetCidrs** (list[string]): List of CIDR blocks for public subnets (e.g., 2-3 subnets) +- **privateSubnetCidrs** (list[string]): List of CIDR blocks for private subnets (e.g., 2-3 subnets) +- **availabilityZones** (list[string]): List of availability zones to use (must match the number of subnets) +- **tagsAdditional** (dict): Additional tags to apply to all resources + +**Note**: The number of subnets is determined by the length of the `publicSubnetCidrs` list. Provide 2 CIDR blocks for 2 subnets, 3 for 3 subnets, etc. + +### Optional Parameters + +- **enableNatGateway** (boolean): Whether to create NAT Gateways in each private subnet. Default: `false` +- **vpcName** (string): Name for the VPC. If not provided, uses the resource name + +## Outputs + +- **vpc_id**: The ID of the VPC +- **vpc_arn**: The ARN of the VPC +- **public_subnet_ids**: List of public subnet IDs +- **private_subnet_ids**: List of private subnet IDs +- **internet_gateway_id**: The ID of the Internet Gateway +- **nat_gateway_ids**: List of NAT Gateway IDs (empty if NAT Gateways are disabled) +- **default_security_group_id**: The ID of the default security group + +## Architecture + +### Without NAT Gateways (enableNatGateway: false) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ VPC │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Public │ │ Public │ │ Public │ │ +│ │ Subnet AZ-1 │ │ Subnet AZ-2 │ │ Subnet AZ-3 │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └──────────────────┴──────────────────┘ │ +│ │ │ +│ ┌───────▼────────┐ │ +│ │ Internet │ │ +│ │ Gateway │ │ +│ └────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Private │ │ Private │ │ Private │ │ +│ │ Subnet AZ-1 │ │ Subnet AZ-2 │ │ Subnet AZ-3 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### With NAT Gateways (enableNatGateway: true) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ VPC │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Public │ │ Public │ │ Public │ │ +│ │ Subnet AZ-1 │ │ Subnet AZ-2 │ │ Subnet AZ-3 │ │ +│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ +│ │ │ NAT GW │ │ │ │ NAT GW │ │ │ │ NAT GW │ │ │ +│ │ └────┬─────┘ │ │ └────┬─────┘ │ │ └────┬─────┘ │ │ +│ └──────┼───────┘ └──────┼───────┘ └──────┼───────┘ │ +│ │ │ │ │ +│ └──────────────────┴──────────────────┘ │ +│ │ │ +│ ┌───────▼────────┐ │ +│ │ Internet │ │ +│ │ Gateway │ │ +│ └────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Private │ │ Private │ │ Private │ │ +│ │ Subnet AZ-1 │ │ Subnet AZ-2 │ │ Subnet AZ-3 │ │ +│ │ ▲ │ │ ▲ │ │ ▲ │ │ +│ │ │ │ │ │ │ │ │ │ │ +│ └──────┼───────┘ └──────┼───────┘ └──────┼───────┘ │ +│ │ │ │ │ +│ └──────────────────┴──────────────────┘ │ +│ Routes to respective NAT Gateway │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Testing + +To run the unit tests: + +```bash +cd pulumi/components/aws/vpc +python3 -m venv venv +source venv/bin/activate +pip install -r requirements-test.txt +python -m pytest tests/ -v +``` + +See [tests/README.md](tests/README.md) for more information on testing. + +## Notes + +- The default security group allows all inbound and outbound traffic. This is suitable for development but should be restricted for production use. +- NAT Gateways incur additional costs. Only enable them if private subnets need internet access. +- The component creates one NAT Gateway per availability zone when enabled, providing high availability but at higher cost. +- All resources are tagged with the provided `tagsAdditional` for easy identification and cost tracking. diff --git a/pulumi/components/aws/vpc/__main__.py b/pulumi/components/aws/vpc/__main__.py new file mode 100644 index 0000000..13867d1 --- /dev/null +++ b/pulumi/components/aws/vpc/__main__.py @@ -0,0 +1,5 @@ +from pulumi.provider.experimental import component_provider_host +from vpc import Vpc + +if __name__ == "__main__": + component_provider_host(name="vpc", components=[Vpc]) diff --git a/pulumi/components/aws/vpc/requirements-test.txt b/pulumi/components/aws/vpc/requirements-test.txt new file mode 100644 index 0000000..e89f5f3 --- /dev/null +++ b/pulumi/components/aws/vpc/requirements-test.txt @@ -0,0 +1,4 @@ +pulumi>=3.130.0 +pulumi-aws>=6.50.1 +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/pulumi/components/aws/vpc/requirements.txt b/pulumi/components/aws/vpc/requirements.txt new file mode 100644 index 0000000..0aef689 --- /dev/null +++ b/pulumi/components/aws/vpc/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.130.0 +pulumi-aws>=6.50.1 diff --git a/pulumi/components/aws/vpc/tests/README.md b/pulumi/components/aws/vpc/tests/README.md new file mode 100644 index 0000000..3fe223a --- /dev/null +++ b/pulumi/components/aws/vpc/tests/README.md @@ -0,0 +1,124 @@ +# VPC Component Unit Tests + +This directory contains unit tests for the VPC Pulumi component. + +## Overview + +The tests validate the VPC component's behavior using Pulumi's mocking framework. No actual AWS resources are created during testing. + +## Test Coverage + +The test suite covers: + +1. **Basic Creation**: VPC without NAT Gateways +2. **NAT Gateway Creation**: VPC with NAT Gateways enabled +3. **Custom VPC Name**: Using a custom name for the VPC +4. **Snake Case Parameters**: Support for both camelCase and snake_case parameter names +5. **Tagging**: Correct application of tags to resources +6. **Output Validation**: All expected outputs are present + +## Setup + +Create a virtual environment and install dependencies: + +```bash +cd pulumi/components/aws/vpc +python3 -m venv venv +source venv/bin/activate +pip install -r requirements-test.txt +``` + +## Running Tests + +### Run all tests + +```bash +python -m pytest tests/ -v +``` + +### Run a specific test + +```bash +python -m pytest tests/test_vpc.py::TestVpc::test_basic_creation_without_nat_gateway -v +``` + +### Run with verbose output + +```bash +python -m pytest tests/ -v -s +``` + +### Run with coverage + +```bash +python -m pytest tests/ --cov=. --cov-report=html +``` + +## Test Structure + +Each test follows this pattern: + +1. **Setup**: Define test arguments with VPC configuration +2. **Execute**: Create the VPC component +3. **Verify**: Check outputs using assertions in an `apply` callback + +Example: + +```python +@pulumi.runtime.test +def test_basic_creation(self): + """Test basic VPC creation.""" + # Setup + args = { + "vpcCidr": "10.0.0.0/16", + "publicSubnetCidrs": ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"], + # ... other args + } + + # Execute + component = Vpc("test-vpc", args) + + # Verify + def check_outputs(outputs): + vpc_id, vpc_arn = outputs + self.assertIsNotNone(vpc_id) + self.assertIn("vpc", vpc_id) + + return pulumi.Output.all( + component.vpc_id, + component.vpc_arn, + ).apply(lambda args: check_outputs(args)) +``` + +## Mock Implementation + +The `MyMocks` class provides mock implementations for: + +- VPC +- Subnets (public and private) +- Internet Gateway +- NAT Gateway +- Elastic IP +- Route Tables +- Routes +- Route Table Associations +- Security Groups + +## Troubleshooting + +### ImportError + +If you get an import error, ensure `conftest.py` is present and the Python path is set correctly. + +### Tests Hang + +If tests hang, make sure test methods return the result of `pulumi.Output.all().apply()`. + +### Mock Not Working + +Verify all resource types used by the component are handled in `MyMocks.new_resource()`. + +## References + +- [Pulumi Unit Testing Guide](https://www.pulumi.com/docs/iac/guides/testing/unit/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/pulumi/components/aws/vpc/tests/__init__.py b/pulumi/components/aws/vpc/tests/__init__.py new file mode 100644 index 0000000..9863f16 --- /dev/null +++ b/pulumi/components/aws/vpc/tests/__init__.py @@ -0,0 +1 @@ +# Test package for VPC component diff --git a/pulumi/components/aws/vpc/tests/conftest.py b/pulumi/components/aws/vpc/tests/conftest.py new file mode 100644 index 0000000..f66c6ba --- /dev/null +++ b/pulumi/components/aws/vpc/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest +import sys +import os + +# Add the parent directory to the Python path so tests can import the module +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/pulumi/components/aws/vpc/tests/test_vpc.py b/pulumi/components/aws/vpc/tests/test_vpc.py new file mode 100644 index 0000000..0c5c27a --- /dev/null +++ b/pulumi/components/aws/vpc/tests/test_vpc.py @@ -0,0 +1,443 @@ +""" +Unit tests for the VPC Pulumi component. + +These tests use Pulumi's unit testing framework to validate the behavior +of the VPC component without creating actual AWS resources. +""" + +import unittest +from typing import Any +import pulumi + + +class MyMocks(pulumi.runtime.Mocks): + """ + Mock implementation for testing Pulumi resources. + + This class intercepts resource creation calls and returns mock values, + allowing us to test the logic of our Pulumi components. + """ + + def new_resource( + self, args: pulumi.runtime.MockResourceArgs + ) -> tuple[str, dict[str, Any]]: + """ + Called when a new resource is being created. + + Returns a tuple of (id, state) for the mocked resource. + """ + outputs = args.inputs + + # Mock VPC + if args.typ == "aws:ec2/vpc:Vpc": + outputs["id"] = f"vpc-{args.name}" + outputs["arn"] = f"arn:aws:ec2:region:account:vpc/{outputs['id']}" + + # Mock Subnet + elif args.typ == "aws:ec2/subnet:Subnet": + outputs["id"] = f"subnet-{args.name}" + outputs["arn"] = f"arn:aws:ec2:region:account:subnet/{outputs['id']}" + + # Mock Internet Gateway + elif args.typ == "aws:ec2/internetGateway:InternetGateway": + outputs["id"] = f"igw-{args.name}" + outputs["arn"] = f"arn:aws:ec2:region:account:internet-gateway/{outputs['id']}" + + # Mock NAT Gateway + elif args.typ == "aws:ec2/natGateway:NatGateway": + outputs["id"] = f"nat-{args.name}" + + # Mock Elastic IP + elif args.typ == "aws:ec2/eip:Eip": + outputs["id"] = f"eip-{args.name}" + outputs["public_ip"] = "1.2.3.4" + + # Mock Route Table + elif args.typ == "aws:ec2/routeTable:RouteTable": + outputs["id"] = f"rtb-{args.name}" + + # Mock Route + elif args.typ == "aws:ec2/route:Route": + outputs["id"] = f"route-{args.name}" + + # Mock Route Table Association + elif args.typ == "aws:ec2/routeTableAssociation:RouteTableAssociation": + outputs["id"] = f"rtbassoc-{args.name}" + + # Mock Security Group + elif args.typ == "aws:ec2/securityGroup:SecurityGroup": + outputs["id"] = f"sg-{args.name}" + outputs["arn"] = f"arn:aws:ec2:region:account:security-group/{outputs['id']}" + + return outputs.get("id", args.name), outputs + + def call(self, args: pulumi.runtime.MockCallArgs) -> dict[str, Any]: + """ + Called when a provider function is invoked. + + Returns mock outputs for the function. + """ + return {} + + +# Set the mocks for all tests in this module +pulumi.runtime.set_mocks(MyMocks()) + + +class TestVpc(unittest.TestCase): + """Test cases for the VPC component.""" + + @pulumi.runtime.test + def test_basic_creation_without_nat_gateway(self): + """ + Test basic VPC creation without NAT Gateways. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + # Define test arguments + args = { + "vpcCidr": "10.0.0.0/16", + "publicSubnetCidrs": [ + "10.0.1.0/24", + "10.0.2.0/24", + "10.0.3.0/24", + ], + "privateSubnetCidrs": [ + "10.0.11.0/24", + "10.0.12.0/24", + "10.0.13.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "tagsAdditional": { + "Environment": "test", + }, + } + + # Create the component + component = Vpc("test-vpc", args) + + # Verify outputs + def check_outputs(outputs): + vpc_id, vpc_arn, public_subnet_ids, private_subnet_ids, igw_id, nat_gw_ids, sg_id = outputs + self.assertIsNotNone(vpc_id) + self.assertIsNotNone(vpc_arn) + self.assertIsNotNone(public_subnet_ids) + self.assertIsNotNone(private_subnet_ids) + self.assertIsNotNone(igw_id) + self.assertIsNotNone(sg_id) + self.assertIn("vpc", vpc_id) + self.assertIn("vpc", vpc_arn) + self.assertEqual(len(public_subnet_ids), 3) + self.assertEqual(len(private_subnet_ids), 3) + # NAT Gateways should be empty when not enabled + self.assertEqual(len(nat_gw_ids), 0) + + return pulumi.Output.all( + component.vpc_id, + component.vpc_arn, + component.public_subnet_ids, + component.private_subnet_ids, + component.internet_gateway_id, + component.nat_gateway_ids, + component.default_security_group_id, + ).apply(lambda args: check_outputs(args)) + + @pulumi.runtime.test + def test_creation_with_nat_gateway(self): + """ + Test VPC creation with NAT Gateways enabled. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpcCidr": "10.1.0.0/16", + "publicSubnetCidrs": [ + "10.1.1.0/24", + "10.1.2.0/24", + "10.1.3.0/24", + ], + "privateSubnetCidrs": [ + "10.1.11.0/24", + "10.1.12.0/24", + "10.1.13.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "enableNatGateway": True, + "tagsAdditional": { + "Environment": "staging", + }, + } + + component = Vpc("test-vpc-nat", args) + + def check_outputs(outputs): + vpc_id, nat_gw_ids = outputs + self.assertIsNotNone(vpc_id) + self.assertIsNotNone(nat_gw_ids) + # Should have 3 NAT Gateways when enabled + self.assertEqual(len(nat_gw_ids), 3) + + return pulumi.Output.all( + component.vpc_id, + component.nat_gateway_ids, + ).apply(lambda args: check_outputs(args)) + + @pulumi.runtime.test + def test_custom_vpc_name(self): + """ + Test VPC creation with custom VPC name. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpcCidr": "10.2.0.0/16", + "publicSubnetCidrs": [ + "10.2.1.0/24", + "10.2.2.0/24", + "10.2.3.0/24", + ], + "privateSubnetCidrs": [ + "10.2.11.0/24", + "10.2.12.0/24", + "10.2.13.0/24", + ], + "availabilityZones": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + ], + "vpcName": "custom-application-vpc", + "tagsAdditional": {}, + } + + component = Vpc("test-custom-name", args) + + def check_outputs(outputs): + vpc_id, = outputs + self.assertIsNotNone(vpc_id) + + return pulumi.Output.all( + component.vpc_id, + ).apply(lambda args: check_outputs(args)) + + @pulumi.runtime.test + def test_snake_case_parameters(self): + """ + Test VPC creation with snake_case parameter names. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpc_cidr": "10.3.0.0/16", + "public_subnet_cidrs": [ + "10.3.1.0/24", + "10.3.2.0/24", + "10.3.3.0/24", + ], + "private_subnet_cidrs": [ + "10.3.11.0/24", + "10.3.12.0/24", + "10.3.13.0/24", + ], + "availability_zones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "enable_nat_gateway": False, + "vpc_name": "snake-case-vpc", + "tags_additional": { + "Environment": "dev", + }, + } + + component = Vpc("test-snake-case", args) + + def check_outputs(outputs): + vpc_id, public_subnets, private_subnets = outputs + self.assertIsNotNone(vpc_id) + self.assertEqual(len(public_subnets), 3) + self.assertEqual(len(private_subnets), 3) + + return pulumi.Output.all( + component.vpc_id, + component.public_subnet_ids, + component.private_subnet_ids, + ).apply(lambda args: check_outputs(args)) + + @pulumi.runtime.test + def test_tagging(self): + """ + Test that tags are correctly applied to resources. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpcCidr": "10.4.0.0/16", + "publicSubnetCidrs": [ + "10.4.1.0/24", + "10.4.2.0/24", + "10.4.3.0/24", + ], + "privateSubnetCidrs": [ + "10.4.11.0/24", + "10.4.12.0/24", + "10.4.13.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "tagsAdditional": { + "Environment": "production", + "Project": "web-app", + "Team": "platform", + "CostCenter": "engineering", + }, + } + + component = Vpc("test-tags", args) + + def check_outputs(outputs): + vpc_id, sg_id = outputs + self.assertIsNotNone(vpc_id) + self.assertIsNotNone(sg_id) + + return pulumi.Output.all( + component.vpc_id, + component.default_security_group_id, + ).apply(lambda args: check_outputs(args)) + + @pulumi.runtime.test + def test_outputs_exist(self): + """ + Test that all expected outputs are present. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpcCidr": "10.5.0.0/16", + "publicSubnetCidrs": [ + "10.5.1.0/24", + "10.5.2.0/24", + "10.5.3.0/24", + ], + "privateSubnetCidrs": [ + "10.5.11.0/24", + "10.5.12.0/24", + "10.5.13.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + "us-west-2c", + ], + "tagsAdditional": {}, + } + + component = Vpc("test-outputs", args) + + # Check that all outputs are defined + self.assertIsNotNone(component.vpc_id) + self.assertIsNotNone(component.vpc_arn) + self.assertIsNotNone(component.public_subnet_ids) + self.assertIsNotNone(component.private_subnet_ids) + self.assertIsNotNone(component.internet_gateway_id) + self.assertIsNotNone(component.nat_gateway_ids) + self.assertIsNotNone(component.default_security_group_id) + + @pulumi.runtime.test + def test_with_two_subnets(self): + """ + Test VPC creation with only 2 subnets instead of 3. + """ + import sys + import os + + sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + from vpc import Vpc + + args = { + "vpcCidr": "10.6.0.0/16", + "publicSubnetCidrs": [ + "10.6.1.0/24", + "10.6.2.0/24", + ], + "privateSubnetCidrs": [ + "10.6.11.0/24", + "10.6.12.0/24", + ], + "availabilityZones": [ + "us-west-2a", + "us-west-2b", + ], + "tagsAdditional": { + "Environment": "test", + }, + } + + component = Vpc("test-two-subnets", args) + + def check_outputs(outputs): + vpc_id, public_subnets, private_subnets = outputs + self.assertIsNotNone(vpc_id) + # Should have exactly 2 subnets + self.assertEqual(len(public_subnets), 2) + self.assertEqual(len(private_subnets), 2) + + return pulumi.Output.all( + component.vpc_id, + component.public_subnet_ids, + component.private_subnet_ids, + ).apply(lambda args: check_outputs(args)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pulumi/components/aws/vpc/vpc.py b/pulumi/components/aws/vpc/vpc.py new file mode 100644 index 0000000..653e819 --- /dev/null +++ b/pulumi/components/aws/vpc/vpc.py @@ -0,0 +1,367 @@ +""" +VPC component for AWS infrastructure. + +This module creates a complete VPC infrastructure including: +- VPC with configurable CIDR block +- Public subnets in different availability zones (configurable number) +- Private subnets in different availability zones (configurable number) +- Internet Gateway for public subnet internet access +- Route tables with appropriate routes +- Default security group allowing all traffic +- Optional NAT Gateways for private subnet internet access +""" +from typing import Optional, TypedDict +import pulumi +from pulumi import ResourceOptions, Output +from pulumi_aws import ec2 + + +class VpcArgs(TypedDict, total=False): + """Arguments for creating a VPC with public and private subnets. + + Required fields: + vpcCidr: CIDR block for the VPC (e.g., "10.0.0.0/16"). + publicSubnetCidrs: List of CIDR blocks for public subnets (e.g., 2-3 subnets). + privateSubnetCidrs: List of CIDR blocks for private subnets (e.g., 2-3 subnets). + availabilityZones: List of availability zones to use (must match subnet count). + tagsAdditional: Additional tags to apply to all resources. + + Optional fields: + enableNatGateway: Whether to create NAT Gateways in private subnets. + Default: False. + vpcName: Name for the VPC. If not provided, uses resource name. + """ + + vpcCidr: pulumi.Input[str] + publicSubnetCidrs: pulumi.Input[list[pulumi.Input[str]]] + privateSubnetCidrs: pulumi.Input[list[pulumi.Input[str]]] + availabilityZones: pulumi.Input[list[pulumi.Input[str]]] + tagsAdditional: pulumi.Input[dict[str, pulumi.Input[str]]] + enableNatGateway: Optional[pulumi.Input[bool]] + vpcName: Optional[pulumi.Input[str]] + + +class Vpc(pulumi.ComponentResource): + """Creates a complete VPC infrastructure with public and private subnets. + + This component creates: + - A VPC with the specified CIDR block + - Public subnets across availability zones (configurable number) + - Private subnets across availability zones (configurable number) + - An Internet Gateway for public internet access + - Route tables with appropriate routes + - A default security group allowing all traffic + - Optional NAT Gateways for private subnet internet access + """ + + vpc_id: pulumi.Output[str] + vpc_arn: pulumi.Output[str] + public_subnet_ids: pulumi.Output[list[str]] + private_subnet_ids: pulumi.Output[list[str]] + internet_gateway_id: pulumi.Output[str] + nat_gateway_ids: pulumi.Output[list[str]] + default_security_group_id: pulumi.Output[str] + + def __init__( + self, + name: str, + args: VpcArgs, + opts: Optional[ResourceOptions] = None, + ) -> None: + """Initialize the VPC component. + + Args: + name: The unique name for this component instance. + args: Configuration arguments for the VPC. + opts: Optional Pulumi resource options. + """ + super().__init__('vpc:index:Vpc', name, {}, opts) + + # Helper to read snake_case or camelCase keys + def pick(d: dict, snake: str, camel: str, default=None): + """Get value from dict supporting both snake_case and camelCase.""" + return d.get(snake, d.get(camel, default)) + + # Get required parameters + vpc_cidr = pick(args, "vpc_cidr", "vpcCidr") + public_subnet_cidrs = pick(args, "public_subnet_cidrs", "publicSubnetCidrs") + private_subnet_cidrs = pick(args, "private_subnet_cidrs", "privateSubnetCidrs") + availability_zones = pick(args, "availability_zones", "availabilityZones") + tags_additional = pick(args, "tags_additional", "tagsAdditional") or {} + + # Get optional parameters with defaults + enable_nat_gateway = pick(args, "enable_nat_gateway", "enableNatGateway", False) + vpc_name = pick(args, "vpc_name", "vpcName", name) + + # Determine the number of subnets dynamically based on the input lists + # For static inputs (from YAML), we can determine this immediately + # For dynamic inputs (from other Pulumi resources), we default to the input length + def determine_subnet_count(cidrs): + """Determine subnet count from CIDR list.""" + if isinstance(cidrs, list): + return len(cidrs) + return 3 # fallback default + + # Try to get the count directly if inputs are static (from YAML) + try: + if isinstance(public_subnet_cidrs, list): + num_subnets = len(public_subnet_cidrs) + else: + num_subnets = 3 # default + except: + num_subnets = 3 # default fallback + + # Create VPC + vpc_tags = { + "Name": vpc_name, + **tags_additional, + } + + vpc = ec2.Vpc( + f"{name}-vpc", + cidr_block=vpc_cidr, + enable_dns_hostnames=True, + enable_dns_support=True, + tags=vpc_tags, + opts=ResourceOptions(parent=self), + ) + + # Create Internet Gateway + igw_tags = { + "Name": f"{vpc_name}-igw", + **tags_additional, + } + + internet_gateway = ec2.InternetGateway( + f"{name}-igw", + vpc_id=vpc.id, + tags=igw_tags, + opts=ResourceOptions(parent=self), + ) + + # Helper functions for extracting values from lists + def get_az(zones, idx): + """Extract availability zone at index from list.""" + if isinstance(zones, list): + return zones[idx] if idx < len(zones) else zones[0] + return zones + + def get_cidr(cidrs, idx): + """Extract CIDR block at index from list.""" + if isinstance(cidrs, list): + return cidrs[idx] if idx < len(cidrs) else cidrs[0] + return cidrs + + # Create public subnets + public_subnets = [] + for i in range(num_subnets): + subnet_tags = { + "Name": f"{vpc_name}-public-subnet-{i+1}", + "Type": "public", + **tags_additional, + } + + subnet = ec2.Subnet( + f"{name}-public-subnet-{i+1}", + vpc_id=vpc.id, + cidr_block=Output.from_input(public_subnet_cidrs).apply( + lambda cidrs, idx=i: get_cidr(cidrs, idx) + ), + availability_zone=Output.from_input(availability_zones).apply( + lambda zones, idx=i: get_az(zones, idx) + ), + map_public_ip_on_launch=True, + tags=subnet_tags, + opts=ResourceOptions(parent=self), + ) + public_subnets.append(subnet) + + # Create public route table + public_rt_tags = { + "Name": f"{vpc_name}-public-rt", + **tags_additional, + } + + public_route_table = ec2.RouteTable( + f"{name}-public-rt", + vpc_id=vpc.id, + tags=public_rt_tags, + opts=ResourceOptions(parent=self), + ) + + # Create route to Internet Gateway for public route table + ec2.Route( + f"{name}-public-route", + route_table_id=public_route_table.id, + destination_cidr_block="0.0.0.0/0", + gateway_id=internet_gateway.id, + opts=ResourceOptions(parent=self), + ) + + # Associate public subnets with public route table + for i, subnet in enumerate(public_subnets): + ec2.RouteTableAssociation( + f"{name}-public-rta-{i+1}", + subnet_id=subnet.id, + route_table_id=public_route_table.id, + opts=ResourceOptions(parent=self), + ) + + # Create private subnets + private_subnets = [] + for i in range(num_subnets): + subnet_tags = { + "Name": f"{vpc_name}-private-subnet-{i+1}", + "Type": "private", + **tags_additional, + } + + subnet = ec2.Subnet( + f"{name}-private-subnet-{i+1}", + vpc_id=vpc.id, + cidr_block=Output.from_input(private_subnet_cidrs).apply( + lambda cidrs, idx=i: get_cidr(cidrs, idx) + ), + availability_zone=Output.from_input(availability_zones).apply( + lambda zones, idx=i: get_az(zones, idx) + ), + map_public_ip_on_launch=False, + tags=subnet_tags, + opts=ResourceOptions(parent=self), + ) + private_subnets.append(subnet) + + # Create NAT Gateways if enabled + nat_gateways = [] + if enable_nat_gateway: + for i, public_subnet in enumerate(public_subnets): + # Allocate Elastic IP for NAT Gateway + eip_tags = { + "Name": f"{vpc_name}-nat-eip-{i+1}", + **tags_additional, + } + + eip = ec2.Eip( + f"{name}-nat-eip-{i+1}", + domain="vpc", + tags=eip_tags, + opts=ResourceOptions(parent=self), + ) + + # Create NAT Gateway + nat_tags = { + "Name": f"{vpc_name}-nat-gw-{i+1}", + **tags_additional, + } + + nat_gateway = ec2.NatGateway( + f"{name}-nat-gw-{i+1}", + subnet_id=public_subnet.id, + allocation_id=eip.id, + tags=nat_tags, + opts=ResourceOptions(parent=self, depends_on=[internet_gateway]), + ) + nat_gateways.append(nat_gateway) + + # Create private route table for this AZ + private_rt_tags = { + "Name": f"{vpc_name}-private-rt-{i+1}", + **tags_additional, + } + + private_route_table = ec2.RouteTable( + f"{name}-private-rt-{i+1}", + vpc_id=vpc.id, + tags=private_rt_tags, + opts=ResourceOptions(parent=self), + ) + + # Create route to NAT Gateway + ec2.Route( + f"{name}-private-route-{i+1}", + route_table_id=private_route_table.id, + destination_cidr_block="0.0.0.0/0", + nat_gateway_id=nat_gateway.id, + opts=ResourceOptions(parent=self), + ) + + # Associate private subnet with route table + ec2.RouteTableAssociation( + f"{name}-private-rta-{i+1}", + subnet_id=private_subnets[i].id, + route_table_id=private_route_table.id, + opts=ResourceOptions(parent=self), + ) + else: + # Create a single private route table without NAT Gateway + private_rt_tags = { + "Name": f"{vpc_name}-private-rt", + **tags_additional, + } + + private_route_table = ec2.RouteTable( + f"{name}-private-rt", + vpc_id=vpc.id, + tags=private_rt_tags, + opts=ResourceOptions(parent=self), + ) + + # Associate all private subnets with this route table + for i, subnet in enumerate(private_subnets): + ec2.RouteTableAssociation( + f"{name}-private-rta-{i+1}", + subnet_id=subnet.id, + route_table_id=private_route_table.id, + opts=ResourceOptions(parent=self), + ) + + # Create default security group that allows all inbound and outbound traffic + sg_tags = { + "Name": f"{vpc_name}-default-sg", + **tags_additional, + } + + default_security_group = ec2.SecurityGroup( + f"{name}-default-sg", + vpc_id=vpc.id, + description="Default security group allowing all inbound and outbound traffic", + ingress=[ + ec2.SecurityGroupIngressArgs( + protocol="-1", + from_port=0, + to_port=0, + cidr_blocks=["0.0.0.0/0"], + description="Allow all inbound traffic", + ) + ], + egress=[ + ec2.SecurityGroupEgressArgs( + protocol="-1", + from_port=0, + to_port=0, + cidr_blocks=["0.0.0.0/0"], + description="Allow all outbound traffic", + ) + ], + tags=sg_tags, + opts=ResourceOptions(parent=self), + ) + + # Set outputs + self.vpc_id = vpc.id + self.vpc_arn = vpc.arn + self.public_subnet_ids = Output.all(*[s.id for s in public_subnets]) + self.private_subnet_ids = Output.all(*[s.id for s in private_subnets]) + self.internet_gateway_id = internet_gateway.id + self.nat_gateway_ids = Output.all(*[ng.id for ng in nat_gateways]) if nat_gateways else Output.from_input([]) + self.default_security_group_id = default_security_group.id + + self.register_outputs({ + 'vpc_id': self.vpc_id, + 'vpc_arn': self.vpc_arn, + 'public_subnet_ids': self.public_subnet_ids, + 'private_subnet_ids': self.private_subnet_ids, + 'internet_gateway_id': self.internet_gateway_id, + 'nat_gateway_ids': self.nat_gateway_ids, + 'default_security_group_id': self.default_security_group_id, + }) diff --git a/pulumi/environments/aws/staging/40-vpc/Pulumi.staging.yaml b/pulumi/environments/aws/staging/40-vpc/Pulumi.staging.yaml new file mode 100644 index 0000000..56de847 --- /dev/null +++ b/pulumi/environments/aws/staging/40-vpc/Pulumi.staging.yaml @@ -0,0 +1 @@ +encryptionsalt: v1:FmQ475apg1o=:v1:z4d8r+tE36E001Xu:jGtBYh9GbZryOPkBylDb42N22brsoA== diff --git a/pulumi/environments/aws/staging/40-vpc/Pulumi.yaml b/pulumi/environments/aws/staging/40-vpc/Pulumi.yaml new file mode 100644 index 0000000..584b574 --- /dev/null +++ b/pulumi/environments/aws/staging/40-vpc/Pulumi.yaml @@ -0,0 +1,43 @@ +name: vpc +description: VPC infrastructure for staging environment +runtime: yaml +packages: + vpc: ../../../../components/aws/vpc@0.0.0 +resources: + aws-provider: + # Version can be found here: https://github.com/pulumi/pulumi-aws + type: pulumi:providers:aws + defaultProvider: true + options: + version: 7.9.1 + staging-vpc: + type: vpc:index:Vpc + properties: + vpcCidr: "10.0.0.0/16" + publicSubnetCidrs: + - "10.0.1.0/24" + - "10.0.2.0/24" + - "10.0.3.0/24" + privateSubnetCidrs: + - "10.0.11.0/24" + - "10.0.12.0/24" + - "10.0.13.0/24" + availabilityZones: + - "us-east-1a" + - "us-east-1b" + - "us-east-1c" + enableNatGateway: false + vpcName: "staging-vpc" + tagsAdditional: + environment: staging + created_by: pulumi + github_repository: devops-with-ai + github_repository_path: "pulumi/environments/aws/staging/40-vpc" +outputs: + vpc_id: ${staging-vpc.vpcId} + vpc_arn: ${staging-vpc.vpcArn} + public_subnet_ids: ${staging-vpc.publicSubnetIds} + private_subnet_ids: ${staging-vpc.privateSubnetIds} + internet_gateway_id: ${staging-vpc.internetGatewayId} + nat_gateway_ids: ${staging-vpc.natGatewayIds} + default_security_group_id: ${staging-vpc.defaultSecurityGroupId} diff --git a/pulumi/environments/aws/staging/40-vpc/sdks/vpc/vpc-0.0.0.yaml b/pulumi/environments/aws/staging/40-vpc/sdks/vpc/vpc-0.0.0.yaml new file mode 100644 index 0000000..a19e940 --- /dev/null +++ b/pulumi/environments/aws/staging/40-vpc/sdks/vpc/vpc-0.0.0.yaml @@ -0,0 +1,3 @@ +packageDeclarationVersion: 1 +name: vpc +version: 0.0.0