402 lines
19 KiB
Bash
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 "════════════════════════════════════════════════════════════"
|