zkt26/sk1/prepare-app.sh

402 lines
19 KiB
Bash

#!/usr/bin/env bash
# prepare-app.sh — Deploy Notes App to AWS (ECS Fargate + RDS + ALB + ACM HTTPS)
# Usage: source .env && ./prepare-app.sh
set -euo pipefail
# ── Required environment variables (set in .env) ─────────────────────────────
: "${AWS_REGION:?Set AWS_REGION in .env}"
: "${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID in .env}"
: "${DOMAIN_NAME:?Set DOMAIN_NAME in .env}"
: "${DB_PASSWORD:?Set DB_PASSWORD in .env}"
: "${DB_USERNAME:=appuser}"
: "${DB_NAME:=appdb}"
APP="notes-app"
CLUSTER="${APP}-cluster"
ECR_BACKEND="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP}-backend"
ECR_FRONTEND="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP}-frontend"
log() { echo "$*"; }
# ── 1. ECR repositories ───────────────────────────────────────────────────────
log "Creating ECR repositories..."
aws ecr describe-repositories --repository-names "${APP}-backend" --region "$AWS_REGION" &>/dev/null \
|| aws ecr create-repository --repository-name "${APP}-backend" --region "$AWS_REGION" --output none
aws ecr describe-repositories --repository-names "${APP}-frontend" --region "$AWS_REGION" &>/dev/null \
|| aws ecr create-repository --repository-name "${APP}-frontend" --region "$AWS_REGION" --output none
# ── 2. Build & push images ────────────────────────────────────────────────────
log "Logging in to ECR..."
aws ecr get-login-password --region "$AWS_REGION" \
| docker login --username AWS --password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
log "Building and pushing backend..."
docker build --platform linux/amd64 -t "${APP}-backend" ./backend
docker tag "${APP}-backend:latest" "${ECR_BACKEND}:latest"
docker push "${ECR_BACKEND}:latest"
log "Building and pushing frontend..."
docker build --platform linux/amd64 -t "${APP}-frontend" ./frontend
docker tag "${APP}-frontend:latest" "${ECR_FRONTEND}:latest"
docker push "${ECR_FRONTEND}:latest"
# ── 3. VPC & networking ───────────────────────────────────────────────────────
log "Looking up default VPC..."
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" \
--query "Vpcs[0].VpcId" --output text --region "$AWS_REGION")
SUBNET_IDS_RAW=$(aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=${VPC_ID}" "Name=defaultForAz,Values=true" \
--query "Subnets[*].SubnetId" --output text --region "$AWS_REGION")
SUBNET1=$(echo "$SUBNET_IDS_RAW" | awk '{print $1}')
SUBNET2=$(echo "$SUBNET_IDS_RAW" | awk '{print $2}')
# ── 4. Security groups ────────────────────────────────────────────────────────
log "Creating security groups..."
get_or_create_sg() {
local name="$1" desc="$2"
local id
id=$(aws ec2 describe-security-groups \
--filters "Name=group-name,Values=${name}" "Name=vpc-id,Values=${VPC_ID}" \
--query "SecurityGroups[0].GroupId" --output text --region "$AWS_REGION" 2>/dev/null)
if [ -z "$id" ] || [ "$id" = "None" ]; then
id=$(aws ec2 create-security-group \
--group-name "$name" --description "$desc" \
--vpc-id "$VPC_ID" --query "GroupId" --output text --region "$AWS_REGION")
fi
echo "$id"
}
ALB_SG=$(get_or_create_sg "${APP}-alb-sg" "ALB SG for ${APP}")
ECS_SG=$(get_or_create_sg "${APP}-ecs-sg" "ECS tasks SG for ${APP}")
RDS_SG=$(get_or_create_sg "${APP}-rds-sg" "RDS SG for ${APP}")
# Add rules (ignore errors if already exist)
aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" --region "$AWS_REGION" \
--ip-permissions \
'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}]' \
'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]' \
--output none 2>/dev/null || true
aws ec2 authorize-security-group-ingress --group-id "$ECS_SG" --region "$AWS_REGION" \
--ip-permissions \
"IpProtocol=tcp,FromPort=80,ToPort=80,UserIdGroupPairs=[{GroupId=${ALB_SG}}]" \
"IpProtocol=tcp,FromPort=5000,ToPort=5000,UserIdGroupPairs=[{GroupId=${ALB_SG}}]" \
--output none 2>/dev/null || true
aws ec2 authorize-security-group-ingress --group-id "$RDS_SG" --region "$AWS_REGION" \
--ip-permissions \
"IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=${ECS_SG}}]" \
--output none 2>/dev/null || true
# ── 5. RDS PostgreSQL ─────────────────────────────────────────────────────────
log "Creating RDS PostgreSQL instance (this takes ~5 min)..."
DB_INSTANCE="${APP}-db"
DB_STATUS=$(aws rds describe-db-instances \
--db-instance-identifier "$DB_INSTANCE" \
--query "DBInstances[0].DBInstanceStatus" --output text --region "$AWS_REGION" 2>/dev/null || echo "none")
if [ "$DB_STATUS" = "none" ]; then
aws rds create-db-instance \
--db-instance-identifier "$DB_INSTANCE" \
--db-instance-class db.t3.micro \
--engine postgres \
--engine-version "16" \
--master-username "$DB_USERNAME" \
--master-user-password "$DB_PASSWORD" \
--db-name "$DB_NAME" \
--allocated-storage 20 \
--storage-type gp2 \
--vpc-security-group-ids "$RDS_SG" \
--no-multi-az \
--no-publicly-accessible \
--backup-retention-period 7 \
--region "$AWS_REGION" \
--output none
fi
log "Waiting for RDS to be available..."
aws rds wait db-instance-available \
--db-instance-identifier "$DB_INSTANCE" --region "$AWS_REGION"
DB_HOST=$(aws rds describe-db-instances \
--db-instance-identifier "$DB_INSTANCE" \
--query "DBInstances[0].Endpoint.Address" --output text --region "$AWS_REGION")
log "RDS endpoint: ${DB_HOST}"
DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}"
# ── 6. Secrets Manager ───────────────────────────────────────────────────────
log "Storing DB password in Secrets Manager..."
SECRET_NAME="${APP}/db-password"
aws secretsmanager describe-secret --secret-id "$SECRET_NAME" --region "$AWS_REGION" &>/dev/null \
|| aws secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string "$DB_PASSWORD" \
--region "$AWS_REGION" --output none
# ── 7. IAM role for ECS task execution ───────────────────────────────────────
log "Creating ECS task execution role..."
EXEC_ROLE="${APP}-exec-role"
aws iam get-role --role-name "$EXEC_ROLE" &>/dev/null || \
aws iam create-role --role-name "$EXEC_ROLE" \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
--output none
aws iam attach-role-policy --role-name "$EXEC_ROLE" \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy 2>/dev/null || true
EXEC_ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${EXEC_ROLE}"
# ── 8. ECS Cluster ────────────────────────────────────────────────────────────
log "Creating ECS cluster..."
aws ecs describe-clusters --clusters "$CLUSTER" --region "$AWS_REGION" \
--query "clusters[?status=='ACTIVE'].clusterName" --output text \
| grep -q "$CLUSTER" \
|| aws ecs create-cluster --cluster-name "$CLUSTER" --region "$AWS_REGION" --output none
# ── 9. CloudWatch log groups ──────────────────────────────────────────────────
aws logs create-log-group --log-group-name "/ecs/${APP}/backend" --region "$AWS_REGION" 2>/dev/null || true
aws logs create-log-group --log-group-name "/ecs/${APP}/frontend" --region "$AWS_REGION" 2>/dev/null || true
# ── 10. ALB ───────────────────────────────────────────────────────────────────
log "Creating Application Load Balancer..."
ALB_ARN=$(aws elbv2 describe-load-balancers --names "${APP}-alb" \
--query "LoadBalancers[0].LoadBalancerArn" --output text --region "$AWS_REGION" 2>/dev/null || echo "")
if [ -z "$ALB_ARN" ] || [ "$ALB_ARN" = "None" ]; then
ALB_ARN=$(aws elbv2 create-load-balancer \
--name "${APP}-alb" \
--subnets "$SUBNET1" "$SUBNET2" \
--security-groups "$ALB_SG" \
--scheme internet-facing \
--type application \
--query "LoadBalancers[0].LoadBalancerArn" --output text --region "$AWS_REGION")
fi
ALB_DNS=$(aws elbv2 describe-load-balancers \
--load-balancer-arns "$ALB_ARN" \
--query "LoadBalancers[0].DNSName" --output text --region "$AWS_REGION")
log "ALB DNS: ${ALB_DNS}"
# ── 11. Target groups ─────────────────────────────────────────────────────────
log "Creating target groups..."
get_or_create_tg() {
local name="$1" port="$2" health_path="$3"
local arn
arn=$(aws elbv2 describe-target-groups --names "$name" \
--query "TargetGroups[0].TargetGroupArn" --output text --region "$AWS_REGION" 2>/dev/null || echo "")
if [ -z "$arn" ] || [ "$arn" = "None" ]; then
arn=$(aws elbv2 create-target-group \
--name "$name" --protocol HTTP --port "$port" \
--vpc-id "$VPC_ID" --target-type ip \
--health-check-path "$health_path" \
--query "TargetGroups[0].TargetGroupArn" --output text --region "$AWS_REGION")
fi
echo "$arn"
}
FRONTEND_TG=$(get_or_create_tg "${APP}-frontend-tg" 80 "/")
BACKEND_TG=$(get_or_create_tg "${APP}-backend-tg" 5000 "/health")
# ── 12. ACM Certificate ───────────────────────────────────────────────────────
log "Requesting ACM certificate for ${DOMAIN_NAME}..."
CERT_ARN=$(aws acm list-certificates \
--query "CertificateSummaryList[?DomainName=='${DOMAIN_NAME}'].CertificateArn | [0]" \
--output text --region "$AWS_REGION")
if [ -z "$CERT_ARN" ] || [ "$CERT_ARN" = "None" ]; then
CERT_ARN=$(aws acm request-certificate \
--domain-name "$DOMAIN_NAME" \
--validation-method DNS \
--query "CertificateArn" --output text --region "$AWS_REGION")
# Print the CNAME record needed for validation
sleep 5
CNAME_NAME=$(aws acm describe-certificate --certificate-arn "$CERT_ARN" --region "$AWS_REGION" \
--query "Certificate.DomainValidationOptions[0].ResourceRecord.Name" --output text)
CNAME_VALUE=$(aws acm describe-certificate --certificate-arn "$CERT_ARN" --region "$AWS_REGION" \
--query "Certificate.DomainValidationOptions[0].ResourceRecord.Value" --output text)
echo ""
echo "════════════════════════════════════════════════════════════"
echo " ACTION REQUIRED: Add these DNS records to your domain:"
echo ""
echo " 1. Certificate validation CNAME:"
echo " Name: ${CNAME_NAME}"
echo " Value: ${CNAME_VALUE}"
echo ""
echo " 2. Point your domain to the ALB:"
echo " ${DOMAIN_NAME} CNAME ${ALB_DNS}"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "Waiting for certificate validation (up to 30 min)..."
aws acm wait certificate-validated --certificate-arn "$CERT_ARN" --region "$AWS_REGION"
fi
# ── 13. ALB Listeners ─────────────────────────────────────────────────────────
log "Creating ALB listeners..."
# HTTP → redirect to HTTPS
HTTP_LISTENER=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" \
--query "Listeners[?Port==\`80\`].ListenerArn | [0]" \
--output text --region "$AWS_REGION" 2>/dev/null || echo "")
if [ -z "$HTTP_LISTENER" ] || [ "$HTTP_LISTENER" = "None" ]; then
aws elbv2 create-listener \
--load-balancer-arn "$ALB_ARN" \
--protocol HTTP --port 80 \
--default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}' \
--region "$AWS_REGION" --output none
fi
# HTTPS listener → default to frontend
HTTPS_LISTENER=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" \
--query "Listeners[?Port==\`443\`].ListenerArn | [0]" \
--output text --region "$AWS_REGION" 2>/dev/null || echo "")
if [ -z "$HTTPS_LISTENER" ] || [ "$HTTPS_LISTENER" = "None" ]; then
HTTPS_LISTENER=$(aws elbv2 create-listener \
--load-balancer-arn "$ALB_ARN" \
--protocol HTTPS --port 443 \
--certificates "CertificateArn=${CERT_ARN}" \
--default-actions "Type=forward,TargetGroupArn=${FRONTEND_TG}" \
--query "Listeners[0].ListenerArn" --output text --region "$AWS_REGION")
fi
# Route /api/* to backend (priority 10)
RULE_EXISTS=$(aws elbv2 describe-rules --listener-arn "$HTTPS_LISTENER" --region "$AWS_REGION" \
--query "Rules[?Conditions[?Values[?contains(@,'/api/*')]]].RuleArn | [0]" --output text 2>/dev/null || echo "")
if [ -z "$RULE_EXISTS" ] || [ "$RULE_EXISTS" = "None" ]; then
aws elbv2 create-rule \
--listener-arn "$HTTPS_LISTENER" \
--priority 10 \
--conditions 'Field=path-pattern,Values=[/api/*]' \
--actions "Type=forward,TargetGroupArn=${BACKEND_TG}" \
--region "$AWS_REGION" --output none
fi
# ── 14. ECS Task Definitions ──────────────────────────────────────────────────
log "Registering ECS task definitions..."
aws ecs register-task-definition \
--family "${APP}-backend" \
--network-mode awsvpc \
--requires-compatibilities FARGATE \
--cpu "256" --memory "512" \
--execution-role-arn "$EXEC_ROLE_ARN" \
--container-definitions "[
{
\"name\": \"backend\",
\"image\": \"${ECR_BACKEND}:latest\",
\"portMappings\": [{\"containerPort\": 5000, \"protocol\": \"tcp\"}],
\"environment\": [{\"name\": \"DATABASE_URL\", \"value\": \"${DATABASE_URL}\"}],
\"logConfiguration\": {
\"logDriver\": \"awslogs\",
\"options\": {
\"awslogs-group\": \"/ecs/${APP}/backend\",
\"awslogs-region\": \"${AWS_REGION}\",
\"awslogs-stream-prefix\": \"backend\"
}
},
\"healthCheck\": {
\"command\": [\"CMD-SHELL\", \"curl -f http://localhost:5000/health || exit 1\"],
\"interval\": 30, \"timeout\": 5, \"retries\": 3, \"startPeriod\": 10
}
}
]" \
--region "$AWS_REGION" --output none
aws ecs register-task-definition \
--family "${APP}-frontend" \
--network-mode awsvpc \
--requires-compatibilities FARGATE \
--cpu "256" --memory "512" \
--execution-role-arn "$EXEC_ROLE_ARN" \
--container-definitions "[
{
\"name\": \"frontend\",
\"image\": \"${ECR_FRONTEND}:latest\",
\"portMappings\": [{\"containerPort\": 80, \"protocol\": \"tcp\"}],
\"logConfiguration\": {
\"logDriver\": \"awslogs\",
\"options\": {
\"awslogs-group\": \"/ecs/${APP}/frontend\",
\"awslogs-region\": \"${AWS_REGION}\",
\"awslogs-stream-prefix\": \"frontend\"
}
}
}
]" \
--region "$AWS_REGION" --output none
# ── 15. ECS Services ──────────────────────────────────────────────────────────
log "Creating ECS services..."
SUBNETS_JSON="[\"${SUBNET1}\",\"${SUBNET2}\"]"
ECS_SG_JSON="[\"${ECS_SG}\"]"
svc_exists() {
aws ecs describe-services --cluster "$CLUSTER" --services "$1" --region "$AWS_REGION" \
--query "services[?status=='ACTIVE'].serviceName" --output text 2>/dev/null | grep -q "$1"
}
svc_exists "${APP}-backend" || \
aws ecs create-service \
--cluster "$CLUSTER" \
--service-name "${APP}-backend" \
--task-definition "${APP}-backend" \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=${SUBNETS_JSON},securityGroups=${ECS_SG_JSON},assignPublicIp=ENABLED}" \
--load-balancers "targetGroupArn=${BACKEND_TG},containerName=backend,containerPort=5000" \
--health-check-grace-period-seconds 60 \
--region "$AWS_REGION" --output none
svc_exists "${APP}-frontend" || \
aws ecs create-service \
--cluster "$CLUSTER" \
--service-name "${APP}-frontend" \
--task-definition "${APP}-frontend" \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=${SUBNETS_JSON},securityGroups=${ECS_SG_JSON},assignPublicIp=ENABLED}" \
--load-balancers "targetGroupArn=${FRONTEND_TG},containerName=frontend,containerPort=80" \
--health-check-grace-period-seconds 60 \
--region "$AWS_REGION" --output none
# ── 16. Wait for services to stabilize ───────────────────────────────────────
log "Waiting for ECS services to stabilize..."
aws ecs wait services-stable \
--cluster "$CLUSTER" \
--services "${APP}-backend" "${APP}-frontend" \
--region "$AWS_REGION"
# ── 17. Initialize database ───────────────────────────────────────────────────
log "Initializing database schema..."
# Run a one-shot ECS task that executes init.sql via the backend image
INIT_SQL=$(cat db/init.sql | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
aws ecs run-task \
--cluster "$CLUSTER" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[\"${SUBNET1}\"],securityGroups=[\"${ECS_SG}\"],assignPublicIp=ENABLED}" \
--task-definition "${APP}-backend" \
--overrides "{
\"containerOverrides\": [{
\"name\": \"backend\",
\"command\": [\"python\", \"-c\", \"import psycopg2,os; conn=psycopg2.connect(os.environ['DATABASE_URL']); cur=conn.cursor(); cur.execute(${INIT_SQL}); conn.commit(); print('DB initialized')\"]
}]
}" \
--region "$AWS_REGION" --output none
echo ""
echo "════════════════════════════════════════════════════════════"
echo " ✅ Deployment complete!"
echo " App URL : https://${DOMAIN_NAME}"
echo " ALB DNS : ${ALB_DNS}"
echo ""
echo " View logs:"
echo " aws logs tail /ecs/${APP}/frontend --follow --region ${AWS_REGION}"
echo " aws logs tail /ecs/${APP}/backend --follow --region ${AWS_REGION}"
echo "════════════════════════════════════════════════════════════"