From 1a64376ee7fad6d42ccd768b4a4b8f43b219dbc3 Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Sun, 14 Dec 2025 21:13:40 +0000 Subject: [PATCH 1/2] Add workflow for "preview" envs --- .github/workflows/preview-env.yml | 144 ++++++++++++ infrastructure/environments/preview/main.tf | 212 ++++++++++++++++++ .../environments/preview/outputs.tf | 14 ++ .../environments/preview/variables.tf | 51 +++++ 4 files changed, 421 insertions(+) create mode 100644 .github/workflows/preview-env.yml create mode 100644 infrastructure/environments/preview/main.tf create mode 100644 infrastructure/environments/preview/outputs.tf create mode 100644 infrastructure/environments/preview/variables.tf diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml new file mode 100644 index 00000000..7be9c77d --- /dev/null +++ b/.github/workflows/preview-env.yml @@ -0,0 +1,144 @@ +name: Preview Environment + +on: + pull_request: + types: [opened, reopened, synchronize, closed] + +env: + AWS_REGION: eu-west-2 + AWS_ACCOUNT_ID: "900119715266" + ECR_REPOSITORY_NAME: "whoami" + TF_STATE_BUCKET: "cds-cdg-dev-tfstate-900119715266" + CORE_STATE_KEY: "dev/terraform.tfstate" + PREVIEW_STATE_PREFIX: "dev/preview/" + +jobs: + preview: + name: Manage preview environment + runs-on: ubuntu-latest + + # Needed for OIDC → AWS (recommended) + permissions: + id-token: write + contents: read + + # One job per branch at a time + concurrency: + group: preview-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + + env: + AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS_ROLE_ARN }} + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + # Configure AWS credentials (OIDC recommended) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Compute branch metadata + id: meta + run: | + # For PRs, head_ref is the source branch name + RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" + + # Sanitize branch name for tags / hostnames (lowercase, only allowed chars) + SANITIZED_BRANCH=$(echo "$RAW_BRANCH" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9._-' '-') + + echo "raw_branch=$RAW_BRANCH" >> $GITHUB_OUTPUT + echo "branch_name=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT + + # ECR repo URL (must match core stack's ECR repo) + ECR_URL="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}" + echo "ecr_url=$ECR_URL" >> $GITHUB_OUTPUT + + # Terraform state key for this preview env + TF_STATE_KEY="${PREVIEW_STATE_PREFIX}${SANITIZED_BRANCH}.tfstate" + echo "tf_state_key=$TF_STATE_KEY" >> $GITHUB_OUTPUT + + # ALB listener rule priority - derive from PR number (must be unique per listener) + # You can tweak this formula if you like. + if [ -n "${{ github.event.number }}" ]; then + PRIORITY=$(( 1000 + ${{ github.event.number }} )) + else + PRIORITY=1999 + fi + echo "alb_rule_priority=$PRIORITY" >> $GITHUB_OUTPUT + + - name: Build Docker image + if: github.event.action != 'closed' + run: | + IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" + ECR_URL="${{ steps.meta.outputs.ecr_url }}" + + docker build \ + --tag "${ECR_URL}:${IMAGE_TAG}" \ + . + + - name: Push Docker image to ECR + if: github.event.action != 'closed' + run: | + IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" + ECR_URL="${{ steps.meta.outputs.ecr_url }}" + + docker push "${ECR_URL}:${IMAGE_TAG}" + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.14.0 + + # ---------- APPLY (PR opened / updated) ---------- + + - name: Terraform init (apply) + if: github.event.action != 'closed' + working-directory: preview-env + run: | + terraform init \ + -backend-config="bucket=${TF_STATE_BUCKET}" \ + -backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \ + -backend-config="region=${AWS_REGION}" + + - name: Terraform apply preview env + if: github.event.action != 'closed' + working-directory: preview-env + env: + TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }} + TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }} + TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} + TF_VAR_base_domain: "aws.astrosoc.org" + run: | + terraform apply -auto-approve + + # ---------- DESTROY (PR closed) ---------- + + - name: Terraform init (destroy) + if: github.event.action == 'closed' + working-directory: preview-env + run: | + terraform init \ + -backend-config="bucket=${TF_STATE_BUCKET}" \ + -backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \ + -backend-config="region=${AWS_REGION}" + + - name: Terraform destroy preview env + if: github.event.action == 'closed' + working-directory: preview-env + env: + TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }} + TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }} + TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} + TF_VAR_base_domain: "aws.astrosoc.org" + run: | + terraform destroy -auto-approve \ No newline at end of file diff --git a/infrastructure/environments/preview/main.tf b/infrastructure/environments/preview/main.tf new file mode 100644 index 00000000..ee499f25 --- /dev/null +++ b/infrastructure/environments/preview/main.tf @@ -0,0 +1,212 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # Typically you'll set a different key per branch in CI (e.g. dev/preview/.tfstate) + backend "s3" { + bucket = "cds-cdg-dev-tfstate-900119715266" + key = "dev/preview/branch_name.tfstate" + region = "eu-west-2" + } +} + +provider "aws" { + region = "eu-west-2" +} + +data "aws_region" "current" {} + +############################ +# 1. Import core outputs +############################ + +data "terraform_remote_state" "core" { + backend = "s3" + config = { + bucket = "cds-cdg-dev-tfstate-900119715266" + key = "dev/terraform.tfstate" + region = "eu-west-2" + } +} + +locals { + # Strip trailing dot if present + base_domain = trimsuffix(var.base_domain, ".") + + # e.g. "feature-123.aws.astrosoc.org" + effective_host_name = "${var.branch_name}.${local.base_domain}" + + branch_safe = replace(replace(var.branch_name, "/", "-"), " ", "-") + log_group_name = "/ecs/preview/${local.branch_safe}" + + # Default image tag to branch_name if not provided + effective_image_tag = length(var.image_tag) > 0 ? var.image_tag : var.branch_name + + # Core outputs + vpc_id = data.terraform_remote_state.core.outputs.vpc_id + private_subnet_ids = data.terraform_remote_state.core.outputs.private_subnet_ids + ecs_tasks_sg_id = data.terraform_remote_state.core.outputs.ecs_tasks_sg_id + alb_listener_arn = data.terraform_remote_state.core.outputs.alb_listener_arn + ecs_cluster_name = data.terraform_remote_state.core.outputs.ecs_cluster_name + ecr_repository_url = data.terraform_remote_state.core.outputs.ecr_repository_url +} + +############################ +# 2. Target group + ALB rule for this branch +############################ + +resource "aws_lb_target_group" "branch" { + name = substr(replace(local.effective_host_name, ".", "-"), 0, 32) + port = var.container_port + protocol = "HTTP" + target_type = "ip" + vpc_id = local.vpc_id + +# health_check { +# path = "/" +# matcher = "200-399" +# interval = 30 +# timeout = 5 +# unhealthy_threshold = 2 +# healthy_threshold = 2 +# } +} + +resource "aws_lb_listener_rule" "branch" { + listener_arn = local.alb_listener_arn + priority = var.alb_rule_priority + + condition { + host_header { + values = [local.effective_host_name] + } + } + + action { + type = "forward" + target_group_arn = aws_lb_target_group.branch.arn + } +} + +############################ +# 3. IAM roles for this preview service +############################ + +resource "aws_iam_role" "execution" { + name = "ecs-preview-${var.branch_name}-exec" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "execution_policy" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "task" { + name = "ecs-preview-${var.branch_name}-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + }] + }) +} + +resource "aws_cloudwatch_log_group" "branch" { + name = local.log_group_name + retention_in_days = var.log_retention_days +} + +############################ +# 4. ECS task definition + service +############################ + +data "aws_ecs_cluster" "cluster" { + cluster_name = local.ecs_cluster_name +} + +resource "aws_ecs_task_definition" "branch" { + family = "preview-${var.branch_name}" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.cpu + memory = var.memory + + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + + runtime_platform { + cpu_architecture = "ARM64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([ + { + name = "app" + image = "${local.ecr_repository_url}:${local.effective_image_tag}" + portMappings = [{ + containerPort = var.container_port + protocol = "tcp" + }] + essential = true + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = local.log_group_name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = local.branch_safe + } + } + } + ]) + + depends_on = [aws_cloudwatch_log_group.branch] +} + +resource "aws_ecs_service" "branch" { + name = "preview-${var.branch_name}" + cluster = data.aws_ecs_cluster.cluster.id + task_definition = aws_ecs_task_definition.branch.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = local.private_subnet_ids + security_groups = [local.ecs_tasks_sg_id] + } + + load_balancer { + target_group_arn = aws_lb_target_group.branch.arn + container_name = "app" + container_port = var.container_port + } + + lifecycle { + ignore_changes = [task_definition] + } + + depends_on = [aws_lb_listener_rule.branch] +} + + diff --git a/infrastructure/environments/preview/outputs.tf b/infrastructure/environments/preview/outputs.tf new file mode 100644 index 00000000..7f5bf93e --- /dev/null +++ b/infrastructure/environments/preview/outputs.tf @@ -0,0 +1,14 @@ +output "url" { + description = "URL of the preview environment" + value = "https://${local.effective_host_name}" +} + +# output "service_arn" { +# description = "ARN of the ECS service for this preview environment" +# value = aws_ecs_service.branch.arn +# } + +output "target_group_arn" { + description = "ARN of the ALB target group for this preview environment" + value = aws_lb_target_group.branch.arn +} diff --git a/infrastructure/environments/preview/variables.tf b/infrastructure/environments/preview/variables.tf new file mode 100644 index 00000000..5d90303a --- /dev/null +++ b/infrastructure/environments/preview/variables.tf @@ -0,0 +1,51 @@ +variable "branch_name" { + description = "The name of the branch for the preview environment." + type = string +} + +variable "base_domain" { + description = "The base domain for the preview environment." + type = string + default = "aws.astrosoc.org" +} + +variable "image_tag" { + description = "The Docker image tag to deploy to ECS." + type = string + default = "" +} + +variable "container_port" { + description = "The port on which the container listens." + type = number + default = 80 +} + +variable "desired_count" { + description = "The desired number of ECS tasks." + type = number + default = 1 +} + +variable "alb_rule_priority" { + description = "The priority for the ALB listener rule." + type = number +} + +variable "cpu" { + description = "The CPU units (1 cpu = 1000) for the ECS task." + type = number + default = 256 +} + +variable "memory" { + description = "The memory (in MiB) for the ECS task." + type = number + default = 512 +} + +variable "log_retention_days" { + description = "Number of days to retain CloudWatch Logs for the preview task." + type = number + default = 14 +} From 028081d2ffe93db932448b102a53fbb933bdbfbd Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Sun, 14 Dec 2025 21:20:42 +0000 Subject: [PATCH 2/2] Linting --- .github/workflows/preview-env.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 7be9c77d..1ee28a75 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -141,4 +141,4 @@ jobs: TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} TF_VAR_base_domain: "aws.astrosoc.org" run: | - terraform destroy -auto-approve \ No newline at end of file + terraform destroy -auto-approve