diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5a50085 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,90 @@ +name: Terraform Provision +'on': + push: + branches: + - main + - port-changes +jobs: + Bookstore-App: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4.1.1 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Install ABP CLI + run: dotnet tool install -g Volo.Abp.Cli + + - name: Install client-side libraries + run: abp install-libs + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3.0.0 + with: + terraform_version: 1.6.3 + + - name: Configure AWS credentials + run: > + echo "AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }}" >> + $GITHUB_ENV + + echo "AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> + $GITHUB_ENV + + - name: Initialize Terraform + run: | + cd infrastructure/ecr + terraform init + + - name : Get ECR Repository URL + id : ecr-repo + run : | + cd infrastructure/ecr + ecr_repo_url=$(terraform output -raw ecr_repository_url) + echo "ECR_REPO_URL=$ecr_repo_url" >> $GITHUB_ENV + echo "The ECR repository URL is ${{ env.ECR_REPO_URL }}" + + - name: Print Repository Name and Images + run: | + REPO_NAME=$(echo "${{ github.repository }}" | awk -F/ '{print $2}' | tr '[:upper:]' '[:lower:]') + echo "Repository Name: $REPO_NAME" + + - name: Build and Push Docker Image + run: | + ecr_repo_url=${{ env.ECR_REPO_URL }} + docker compose -f compose.yml build + + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin $ecr_repo_url + + docker images + + REPO_URI="${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com" + + REPO_NAME=$(echo "${{ github.repository }}" | awk -F/ '{print $2}' | tr '[:upper:]' '[:lower:]') + + IMAGES=("app" "web" "auth" "migrator") + + for IMAGE in "${IMAGES[@]}"; do + aws ecr create-repository --repository-name $REPO_NAME-$IMAGE --region ${{ secrets.AWS_REGION }} + IMAGE_NAME="$REPO_URI/$REPO_NAME-$IMAGE:${{ github.sha }}" + docker tag $REPO_NAME-$IMAGE $IMAGE_NAME + docker push $IMAGE_NAME + done + + - name: Provision underlying infrastructure for ECS Cluster + run: | + cd infrastructure/ecs-cluster + terraform apply -auto-approve diff --git a/.gitignore b/.gitignore index a734301..ac3f564 100644 --- a/.gitignore +++ b/.gitignore @@ -266,4 +266,39 @@ src/Acme.BookStore.Blazor.Server.Tiered/Logs/* .yarn !certificate.pfx node_modules -appsettings.*.json \ No newline at end of file +appsettings.*.json + +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc \ No newline at end of file diff --git a/compose.yml b/compose.yml index 249e21e..7b30bed 100644 --- a/compose.yml +++ b/compose.yml @@ -35,7 +35,7 @@ services: migrator: condition: service_completed_successfully ports: - - "5001:443" + - "5001:80" healthcheck: test: ["CMD", "curl", "-k -v", "https://localhost/swagger/index.html"] interval: 10s @@ -58,7 +58,7 @@ services: app: condition: service_started ports: - - "5002:443" + - "5002:80" auth: build: @@ -76,7 +76,7 @@ services: app: condition: service_started ports: - - "5003:443" + - "5003:80" migrator: build: diff --git a/infrastructure/ecr/main.tf b/infrastructure/ecr/main.tf new file mode 100644 index 0000000..e4fae46 --- /dev/null +++ b/infrastructure/ecr/main.tf @@ -0,0 +1,22 @@ +provider "aws" { + region = "eu-north-1" +} + +resource "aws_ecr_repository" "this" { + name = "myrepo" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } +} + +output "ecr_repository_url" { + value = aws_ecr_repository.this.repository_url +} + + +# resource "aws_instance" "example" { +# ami = "ami-0fe8bec493a81c7da" +# instance_type = "t3.micro" +# } \ No newline at end of file diff --git a/infrastructure/ecs-cluster/01-network.tf b/infrastructure/ecs-cluster/01-network.tf new file mode 100644 index 0000000..49f2159 --- /dev/null +++ b/infrastructure/ecs-cluster/01-network.tf @@ -0,0 +1,141 @@ +provider "aws" { + region = local.region +} + +resource "aws_vpc" "this" { + cidr_block = local.vpc_cidr + tags = { + Name = "${local.resource_tag_prefix}-vpc" + } +} + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + tags = { + Name = "${local.resource_tag_prefix}-igw" + } +} + +resource "aws_subnet" "public" { + count = length(local.public_subnet_cidrs) + cidr_block = local.public_subnet_cidrs[count.index] + availability_zone = local.availability_zones[count.index] + vpc_id = aws_vpc.this.id + map_public_ip_on_launch = true + + tags = { + Name = "${local.resource_tag_prefix}-public-subnet-${count.index + 1}" + } +} + +resource "aws_subnet" "private" { + count = length(local.private_subnet_cidrs) + cidr_block = local.private_subnet_cidrs[count.index] + availability_zone = local.availability_zones[count.index] + vpc_id = aws_vpc.this.id + + tags = { + Name = "${local.resource_tag_prefix}-private-subnet-${count.index + 1}" + } +} + +resource "aws_eip" "this" { + count = local.elastic_ip_count + vpc = true + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + count = local.nat_gateway_count + subnet_id = element(aws_subnet.public.*.id, count.index) + allocation_id = element(aws_eip.this.*.id, count.index) + tags = { + Name = "${local.resource_tag_prefix}-nat-gateway-${count.index + 1}" + } +} + +resource "aws_route_table" "public_route_table" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = { + Name = "${local.resource_tag_prefix}-public-rt" + } +} + +resource "aws_route_table_association" "public_associations" { + count = length(aws_subnet.public.*.id) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public_route_table.id +} + +resource "aws_route_table" "private" { + count = length(aws_nat_gateway.this.*.id) + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this[count.index].id + } + + tags = { + Name = "${local.resource_tag_prefix}-private-rt-${count.index + 1}" + } +} + +resource "aws_route_table_association" "private_associations" { + count = length(aws_subnet.private.*.id) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} + + +resource "aws_security_group" "lb" { + name = "${local.resource_tag_prefix}-ecs-ec2-alb-sg" + vpc_id = aws_vpc.this.id + + ingress { + protocol = "tcp" + from_port = 80 + to_port = 80 + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "ecs_task" { + name = "${local.resource_tag_prefix}-ecs-task-sg" + vpc_id = aws_vpc.this.id + + ingress { + protocol = "tcp" + from_port = 80 + to_port = 80 + security_groups = [aws_security_group.lb.id] + } + + ingress { + protocol = "tcp" + from_port = 5001 + to_port = 5001 + security_groups = [aws_security_group.lb.id] + } + + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/infrastructure/ecs-cluster/02-autoscaling.tf b/infrastructure/ecs-cluster/02-autoscaling.tf new file mode 100644 index 0000000..5bd478c --- /dev/null +++ b/infrastructure/ecs-cluster/02-autoscaling.tf @@ -0,0 +1,50 @@ +resource "aws_iam_role" "this" { + name = "${local.resource_tag_prefix}-ecs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "ec2.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "service_policy_attachment" { + role = aws_iam_role.this.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" +} + +resource "aws_iam_instance_profile" "this" { + name = "${local.resource_tag_prefix}-ecs-instance-profile" + role = aws_iam_role.this.name +} + +resource "aws_launch_configuration" "this" { + name_prefix = "${local.resource_tag_prefix}-ecs-launch-config" + image_id = local.ami_id + instance_type = local.instance_type + iam_instance_profile = aws_iam_instance_profile.this.name + security_groups = [aws_security_group.ecs_task.id] + user_data = <<-EOF + #!/bin/bash + echo 'ECS_CLUSTER=${local.cluster_name}' >> /etc/ecs/ecs.config + yum update -y + yum install -y aws-cli + EOF +} + +resource "aws_autoscaling_group" "this" { + launch_configuration = aws_launch_configuration.this.name + vpc_zone_identifier = aws_subnet.private.*.id + min_size = local.asg_min_size + desired_capacity = local.asg_desired_size + max_size = local.asg_max_size + health_check_grace_period = 300 + health_check_type = "EC2" +} \ No newline at end of file diff --git a/infrastructure/ecs-cluster/03-ecs.tf b/infrastructure/ecs-cluster/03-ecs.tf new file mode 100644 index 0000000..16be51d --- /dev/null +++ b/infrastructure/ecs-cluster/03-ecs.tf @@ -0,0 +1,41 @@ +resource "aws_ecs_cluster" "this" { + name = local.cluster_name +} + +resource "aws_ecs_task_definition" "this" { + family = "${local.resource_tag_prefix}-task-family" + requires_compatibilities = ["EC2"] + + container_definitions = jsonencode([ + { + name = "bookstore-app" + image = "846819257656.dkr.ecr.eu-north-1.amazonaws.com/terraform-infra-app:15fd46b4e726f54900673d5e87451b5e1456e9b8" + cpu = 512 + memory = 1024 + portMappings = [ + { + containerPort = 80 + hostPort = 0 + protocol = "tcp" + } + ] + } + ]) +} + +resource "aws_ecs_service" "this" { + name = "${local.resource_tag_prefix}-ecs-service" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.this.arn + desired_count = local.service_instance_count + + launch_type = "EC2" + + load_balancer { + target_group_arn = aws_lb_target_group.ecs_target_group.arn + container_name = "bookstore-app" + container_port = 80 + } + + depends_on = [aws_lb_listener.alb_listener] +} \ No newline at end of file diff --git a/infrastructure/ecs-cluster/04-loadbalancer.tf b/infrastructure/ecs-cluster/04-loadbalancer.tf new file mode 100644 index 0000000..cc91db6 --- /dev/null +++ b/infrastructure/ecs-cluster/04-loadbalancer.tf @@ -0,0 +1,33 @@ + +resource "aws_lb" "ecs_alb" { + name = "${local.resource_tag_prefix}-ecs-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.lb.id] + subnets = aws_subnet.public.*.id +} + + +resource "aws_lb_listener" "alb_listener" { + load_balancer_arn = aws_lb.ecs_alb.arn + port = 80 + protocol = "HTTP" + + default_action { + target_group_arn = aws_lb_target_group.ecs_target_group.arn + type = "forward" + } +} + +resource "aws_lb_target_group" "ecs_target_group" { + name = "${local.resource_tag_prefix}-ecs-target-group" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.this.id + target_type = "instance" + health_check { + path = "/" + protocol = "HTTP" + } +} + diff --git a/infrastructure/ecs-cluster/local.tf b/infrastructure/ecs-cluster/local.tf new file mode 100644 index 0000000..ba5b26b --- /dev/null +++ b/infrastructure/ecs-cluster/local.tf @@ -0,0 +1,17 @@ +locals { + vpc_cidr = "10.0.0.0/16" + region = "eu-north-1" + resource_tag_prefix = "bookstore" + public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] + private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24"] + availability_zones = ["eu-north-1a", "eu-north-1b"] + ami_id = "ami-0cc713d0428957edd" + instance_type = "t3.small" + cluster_name = "bookstore-cluster" + service_instance_count = 3 + elastic_ip_count = 2 + nat_gateway_count = 2 + asg_min_size = 1 + asg_max_size = 2 + asg_desired_size = 1 +} \ No newline at end of file