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
144 changes: 144 additions & 0 deletions .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
@@ -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
212 changes: 212 additions & 0 deletions infrastructure/environments/preview/main.tf
Original file line number Diff line number Diff line change
@@ -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/<branch>.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]
}


14 changes: 14 additions & 0 deletions infrastructure/environments/preview/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading