diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48818c3..09d453b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,9 +117,10 @@ jobs: cd infrastructure cdk diff --all - - name: Deploy Infrastructure to AWS + - name: Deploy Infrastructure (Phase 1 - Without ECS Service) run: | cd infrastructure + echo "Deploying infrastructure without ECS service (allows image push first)..." cdk deploy --all --require-approval never env: CDK_DEFAULT_ACCOUNT: ${{ vars.AWS_ACCOUNT_ID }} @@ -155,6 +156,15 @@ jobs: echo "Image pushed to ECR: $ECR_REPOSITORY:latest" + - name: Deploy Infrastructure (Phase 2 - With ECS Service) + run: | + cd infrastructure + echo "Deploying ECS service now that image is in ECR..." + cdk deploy TodosEcsStack --require-approval never -c createEcsService=true + env: + CDK_DEFAULT_ACCOUNT: ${{ vars.AWS_ACCOUNT_ID }} + CDK_DEFAULT_REGION: ${{ vars.AWS_REGION }} + - name: Verify Deployment run: | echo "Verifying ECS service deployment..." diff --git a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/EcsStack.java b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/EcsStack.java index c72ae3d..b22ab35 100644 --- a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/EcsStack.java +++ b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/EcsStack.java @@ -22,15 +22,18 @@ /** * ECS Fargate stack for running the Todos application. Deploys the native image container in - * private subnets with secure configuration. + * private subnets with secure configuration. ECS service creation is optional to allow image push + * before service deployment. */ public class EcsStack extends Stack { private final FargateService service; private final Repository ecrRepository; + private final Cluster cluster; + private final FargateTaskDefinition taskDefinition; /** - * Creates a new EcsStack with Fargate service for the Todos application. + * Creates a new EcsStack with optional Fargate service for the Todos application. * * @param scope the parent construct * @param id the construct ID @@ -38,6 +41,8 @@ public class EcsStack extends Stack { * @param vpc the VPC to deploy into * @param securityGroup the security group for the service * @param keyspaceName the Keyspaces database name + * @param createService whether to create the ECS service (false for initial deploy before image + * push) */ public EcsStack( final Construct scope, @@ -45,24 +50,43 @@ public EcsStack( final StackProps props, final IVpc vpc, final ISecurityGroup securityGroup, - final String keyspaceName) { + final String keyspaceName, + final boolean createService) { super(scope, id, props); this.ecrRepository = createEcrRepository(); - Cluster cluster = createCluster(vpc); - FargateTaskDefinition taskDefinition = createTaskDefinition(keyspaceName); - this.service = createService(cluster, taskDefinition, securityGroup); - - // Output service ARN and ECR repository URI for reference - CfnOutput.Builder.create(this, "ServiceArn") - .value(service.getServiceArn()) - .description("ECS Service ARN for Todos application") - .build(); - + this.cluster = createCluster(vpc); + this.taskDefinition = createTaskDefinition(keyspaceName); + + // Only create service if requested (allows ECR push before service creation) + if (createService) { + this.service = createService(cluster, taskDefinition, securityGroup); + + // Output service ARN when service is created + CfnOutput.Builder.create(this, "ServiceArn") + .value(service.getServiceArn()) + .description("ECS Service ARN for Todos application") + .build(); + } else { + this.service = null; + // Output instructions for next deployment step + CfnOutput.Builder.create(this, "NextSteps") + .value("Push Docker image to ECR, then redeploy with -c createEcsService=true") + .description("Instructions for completing deployment") + .build(); + } + + // Always output ECR repository URI CfnOutput.Builder.create(this, "EcrRepositoryUri") .value(ecrRepository.getRepositoryUri()) .description("ECR Repository URI for Todos application image") .build(); + + // Output cluster name for reference + CfnOutput.Builder.create(this, "ClusterName") + .value(cluster.getClusterName()) + .description("ECS Cluster name") + .build(); } /** Creates ECR repository for the Todos application image. */ diff --git a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/KeyspacesStack.java b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/KeyspacesStack.java index fdd7541..b258bdd 100644 --- a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/KeyspacesStack.java +++ b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/KeyspacesStack.java @@ -30,8 +30,12 @@ public KeyspacesStack(final Construct scope, final String id, final StackProps p CfnKeyspace keyspace = createKeyspace(); this.keyspaceName = keyspace.getKeyspaceName(); - createChecklistTable(keyspace); - createTodoTable(keyspace); + CfnTable checklistTable = createChecklistTable(keyspace); + CfnTable todoTable = createTodoTable(keyspace); + + // Explicitly declare CloudFormation dependencies to ensure keyspace is created first + checklistTable.addDependency(keyspace); + todoTable.addDependency(keyspace); // Output keyspace name for application configuration CfnOutput.Builder.create(this, "KeyspaceName") @@ -46,8 +50,8 @@ private CfnKeyspace createKeyspace() { } /** Creates checklist table matching the domain model. */ - private void createChecklistTable(final CfnKeyspace keyspace) { - CfnTable.Builder.create(this, "ChecklistTable") + private CfnTable createChecklistTable(final CfnKeyspace keyspace) { + return CfnTable.Builder.create(this, "ChecklistTable") .keyspaceName(keyspace.getKeyspaceName()) .tableName("checklist") .partitionKeyColumns( @@ -69,8 +73,8 @@ private void createChecklistTable(final CfnKeyspace keyspace) { } /** Creates todo table matching the domain model. */ - private void createTodoTable(final CfnKeyspace keyspace) { - CfnTable.Builder.create(this, "TodoTable") + private CfnTable createTodoTable(final CfnKeyspace keyspace) { + return CfnTable.Builder.create(this, "TodoTable") .keyspaceName(keyspace.getKeyspaceName()) .tableName("todo") .partitionKeyColumns( diff --git a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/NetworkStack.java b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/NetworkStack.java index 5d0d36d..b5ea832 100644 --- a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/NetworkStack.java +++ b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/NetworkStack.java @@ -3,24 +3,32 @@ import java.util.List; import software.amazon.awscdk.Stack; import software.amazon.awscdk.StackProps; +import software.amazon.awscdk.services.ec2.GatewayVpcEndpointAwsService; +import software.amazon.awscdk.services.ec2.GatewayVpcEndpointOptions; import software.amazon.awscdk.services.ec2.IVpc; +import software.amazon.awscdk.services.ec2.InterfaceVpcEndpointAwsService; +import software.amazon.awscdk.services.ec2.InterfaceVpcEndpointOptions; +import software.amazon.awscdk.services.ec2.Port; import software.amazon.awscdk.services.ec2.SecurityGroup; import software.amazon.awscdk.services.ec2.SubnetConfiguration; +import software.amazon.awscdk.services.ec2.SubnetSelection; import software.amazon.awscdk.services.ec2.SubnetType; import software.amazon.awscdk.services.ec2.Vpc; import software.constructs.Construct; /** * Network infrastructure stack for Todos application. Creates VPC with private subnets only - no - * public access for security. + * public access for security. Uses VPC endpoints instead of NAT Gateway for cost-effective AWS + * service access. */ public class NetworkStack extends Stack { private final IVpc vpc; private final SecurityGroup privateSecurityGroup; + private final SecurityGroup vpcEndpointSecurityGroup; /** - * Creates a new NetworkStack with private-only networking infrastructure. + * Creates a new NetworkStack with private-only networking infrastructure and VPC endpoints. * * @param scope the parent construct * @param id the construct ID @@ -31,6 +39,13 @@ public NetworkStack(final Construct scope, final String id, final StackProps pro this.vpc = createVpc(); this.privateSecurityGroup = createPrivateSecurityGroup(); + this.vpcEndpointSecurityGroup = createVpcEndpointSecurityGroup(); + + // Create VPC endpoints for AWS services (no NAT Gateway needed) + createS3GatewayEndpoint(); + createEcrEndpoints(); + createCloudWatchLogsEndpoint(); + createKeyspacesEndpoint(); } /** @@ -65,14 +80,92 @@ private SecurityGroup createPrivateSecurityGroup() { .build(); // Allow internal communication within the security group - sg.addIngressRule( - sg, - software.amazon.awscdk.services.ec2.Port.allTraffic(), - "Allow internal communication within security group"); + sg.addIngressRule(sg, Port.allTraffic(), "Allow internal communication within security group"); return sg; } + /** + * Creates security group for VPC endpoints. Allows HTTPS traffic from application security group. + */ + private SecurityGroup createVpcEndpointSecurityGroup() { + SecurityGroup sg = + SecurityGroup.Builder.create(this, "VpcEndpointSecurityGroup") + .vpc(vpc) + .description("Security group for VPC endpoints") + .allowAllOutbound(false) + .build(); + + // Allow HTTPS from application security group to VPC endpoints (ECR, CloudWatch, etc.) + sg.addIngressRule(privateSecurityGroup, Port.tcp(443), "Allow HTTPS from application"); + + // Allow Keyspaces port from application (Cassandra uses port 9142 with TLS) + sg.addIngressRule(privateSecurityGroup, Port.tcp(9142), "Allow Cassandra from application"); + + return sg; + } + + /** Creates S3 Gateway endpoint for ECR image layers (no cost, no hourly charge). */ + private void createS3GatewayEndpoint() { + vpc.addGatewayEndpoint( + "S3GatewayEndpoint", + GatewayVpcEndpointOptions.builder() + .service(GatewayVpcEndpointAwsService.S3) + .subnets( + List.of(SubnetSelection.builder().subnetType(SubnetType.PRIVATE_ISOLATED).build())) + .build()); + } + + /** Creates ECR VPC endpoints for Docker registry access without NAT Gateway. */ + private void createEcrEndpoints() { + // ECR API endpoint - for Docker client API calls + vpc.addInterfaceEndpoint( + "EcrApiEndpoint", + InterfaceVpcEndpointOptions.builder() + .service(InterfaceVpcEndpointAwsService.ECR) + .subnets(SubnetSelection.builder().subnetType(SubnetType.PRIVATE_ISOLATED).build()) + .securityGroups(List.of(vpcEndpointSecurityGroup)) + .privateDnsEnabled(true) + .build()); + + // ECR Docker endpoint - for pulling Docker images + vpc.addInterfaceEndpoint( + "EcrDockerEndpoint", + InterfaceVpcEndpointOptions.builder() + .service(InterfaceVpcEndpointAwsService.ECR_DOCKER) + .subnets(SubnetSelection.builder().subnetType(SubnetType.PRIVATE_ISOLATED).build()) + .securityGroups(List.of(vpcEndpointSecurityGroup)) + .privateDnsEnabled(true) + .build()); + } + + /** Creates CloudWatch Logs endpoint for application logging without NAT Gateway. */ + private void createCloudWatchLogsEndpoint() { + vpc.addInterfaceEndpoint( + "CloudWatchLogsEndpoint", + InterfaceVpcEndpointOptions.builder() + .service(InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS) + .subnets(SubnetSelection.builder().subnetType(SubnetType.PRIVATE_ISOLATED).build()) + .securityGroups(List.of(vpcEndpointSecurityGroup)) + .privateDnsEnabled(true) + .build()); + } + + /** + * Creates Keyspaces (Cassandra) endpoint for database access without NAT Gateway. Keyspaces + * requires HTTPS (port 9142 uses TLS). + */ + private void createKeyspacesEndpoint() { + vpc.addInterfaceEndpoint( + "KeyspacesEndpoint", + InterfaceVpcEndpointOptions.builder() + .service(InterfaceVpcEndpointAwsService.KEYSPACES) + .subnets(SubnetSelection.builder().subnetType(SubnetType.PRIVATE_ISOLATED).build()) + .securityGroups(List.of(vpcEndpointSecurityGroup)) + .privateDnsEnabled(true) + .build()); + } + public IVpc getVpc() { return vpc; } @@ -80,4 +173,8 @@ public IVpc getVpc() { public SecurityGroup getPrivateSecurityGroup() { return privateSecurityGroup; } + + public SecurityGroup getVpcEndpointSecurityGroup() { + return vpcEndpointSecurityGroup; + } } diff --git a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/TodosInfrastructureApp.java b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/TodosInfrastructureApp.java index 8be3c8a..74ca6af 100644 --- a/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/TodosInfrastructureApp.java +++ b/infrastructure/src/main/java/net/ssimmie/todos/infrastructure/TodosInfrastructureApp.java @@ -25,13 +25,19 @@ public static void main(final String[] args) { Environment env = createEnvironment(); StackProps stackProps = StackProps.builder().env(env).build(); + // Read context to determine if ECS service should be created + // Default to false for initial deployment (allows ECR image push first) + Object contextValue = app.getNode().tryGetContext("createEcsService"); + boolean createEcsService = + contextValue != null && Boolean.parseBoolean(contextValue.toString()); + // Create network infrastructure (VPC, subnets, security groups) - private only NetworkStack networkStack = new NetworkStack(app, "TodosNetworkStack", stackProps); // Create Keyspaces database KeyspacesStack keyspacesStack = new KeyspacesStack(app, "TodosKeyspacesStack", stackProps); - // Create ECS service for application (private subnets only) + // Create ECS stack (ECR + optional service based on context parameter) EcsStack ecsStack = new EcsStack( app, @@ -39,7 +45,8 @@ public static void main(final String[] args) { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - keyspacesStack.getKeyspaceName()); + keyspacesStack.getKeyspaceName(), + createEcsService); ecsStack.addDependency(networkStack); ecsStack.addDependency(keyspacesStack); diff --git a/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/EcsStackTest.java b/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/EcsStackTest.java index 24f91b5..f48d175 100644 --- a/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/EcsStackTest.java +++ b/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/EcsStackTest.java @@ -28,7 +28,8 @@ void constructor_shouldAcceptParameters() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "test-keyspace"); + "test-keyspace", + true); assertThat(ecsStack).isNotNull(); assertThat(ecsStack.getService()).isNotNull(); @@ -51,7 +52,8 @@ void getService_shouldReturnConsistentInstance() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "test-keyspace"); + "test-keyspace", + true); assertThat(ecsStack.getService()).isNotNull(); assertThat(ecsStack.getService()).isSameAs(ecsStack.getService()); @@ -74,7 +76,8 @@ void constructor_shouldHandleDifferentKeyspaceNames() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "keyspace1"); + "keyspace1", + true); EcsStack stack2 = new EcsStack( @@ -83,7 +86,8 @@ void constructor_shouldHandleDifferentKeyspaceNames() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "keyspace2"); + "keyspace2", + true); assertThat(stack1.getService()).isNotNull(); assertThat(stack2.getService()).isNotNull(); @@ -106,7 +110,8 @@ void getEcrRepository_shouldReturnConsistentInstance() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "test-keyspace"); + "test-keyspace", + true); assertThat(ecsStack.getEcrRepository()).isNotNull(); assertThat(ecsStack.getEcrRepository()).isSameAs(ecsStack.getEcrRepository()); @@ -129,7 +134,8 @@ void constructor_shouldCreateEcrRepositoryForEachStack() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "keyspace1"); + "keyspace1", + true); EcsStack stack2 = new EcsStack( @@ -138,10 +144,59 @@ void constructor_shouldCreateEcrRepositoryForEachStack() { stackProps, networkStack.getVpc(), networkStack.getPrivateSecurityGroup(), - "keyspace2"); + "keyspace2", + true); assertThat(stack1.getEcrRepository()).isNotNull(); assertThat(stack2.getEcrRepository()).isNotNull(); assertThat(stack1.getEcrRepository()).isNotSameAs(stack2.getEcrRepository()); } + + @Test + void constructor_shouldNotCreateServiceWhenFlagIsFalse() { + App app = new App(); + StackProps stackProps = + StackProps.builder() + .env(Environment.builder().account("123456789012").region("eu-west-2").build()) + .build(); + + NetworkStack networkStack = new NetworkStack(app, "TestNetwork6", stackProps); + EcsStack ecsStack = + new EcsStack( + app, + "TestEcsStack6", + stackProps, + networkStack.getVpc(), + networkStack.getPrivateSecurityGroup(), + "test-keyspace", + false); + + assertThat(ecsStack).isNotNull(); + assertThat(ecsStack.getService()).isNull(); + assertThat(ecsStack.getEcrRepository()).isNotNull(); + } + + @Test + void constructor_shouldCreateServiceWhenFlagIsTrue() { + App app = new App(); + StackProps stackProps = + StackProps.builder() + .env(Environment.builder().account("123456789012").region("eu-west-2").build()) + .build(); + + NetworkStack networkStack = new NetworkStack(app, "TestNetwork7", stackProps); + EcsStack ecsStack = + new EcsStack( + app, + "TestEcsStack7", + stackProps, + networkStack.getVpc(), + networkStack.getPrivateSecurityGroup(), + "test-keyspace", + true); + + assertThat(ecsStack).isNotNull(); + assertThat(ecsStack.getService()).isNotNull(); + assertThat(ecsStack.getEcrRepository()).isNotNull(); + } } diff --git a/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/NetworkStackTest.java b/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/NetworkStackTest.java index cb4f9c1..b457b7c 100644 --- a/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/NetworkStackTest.java +++ b/infrastructure/src/test/java/net/ssimmie/todos/infrastructure/NetworkStackTest.java @@ -24,6 +24,7 @@ void constructor_shouldCreateStackWithBasicComponents() { assertThat(networkStack).isNotNull(); assertThat(networkStack.getVpc()).isNotNull(); assertThat(networkStack.getPrivateSecurityGroup()).isNotNull(); + assertThat(networkStack.getVpcEndpointSecurityGroup()).isNotNull(); } @Test @@ -59,4 +60,21 @@ void getPrivateSecurityGroup_shouldReturnConsistentInstance() { assertThat(sg2).isNotNull(); assertThat(sg1).isSameAs(sg2); } + + @Test + void getVpcEndpointSecurityGroup_shouldReturnConsistentInstance() { + App app = new App(); + StackProps stackProps = + StackProps.builder() + .env(Environment.builder().account("123456789012").region("eu-west-2").build()) + .build(); + + NetworkStack networkStack = new NetworkStack(app, "TestNetworkStack4", stackProps); + SecurityGroup sg1 = networkStack.getVpcEndpointSecurityGroup(); + SecurityGroup sg2 = networkStack.getVpcEndpointSecurityGroup(); + + assertThat(sg1).isNotNull(); + assertThat(sg2).isNotNull(); + assertThat(sg1).isSameAs(sg2); + } }