diff --git a/.github/GITHUB_ACTIONS_CICD.md b/.github/GITHUB_ACTIONS_CICD.md deleted file mode 100644 index cfa97b0..0000000 --- a/.github/GITHUB_ACTIONS_CICD.md +++ /dev/null @@ -1,114 +0,0 @@ -# GitHub Actions CI/CD Guide - -Reference for CI validation and deployment workflows in this repository. - -## Workflows - -- `ci.yml` — lint, tests, security/dependency checks, Terraform plan validation -- `staging.yml` — staging build/apply/deploy/smoke-test -- `release.yml` — semantic release and production deploy flow -- `_smoke-test.yml` — reusable post-deploy health check - -## `ci.yml` - -Triggers: - -- `pull_request` to `main` and `develop` -- `push` to `main` and `develop` -- `workflow_dispatch` - -Responsibilities: - -- Conventional commit check (PR) -- Backend lint + unit/integration tests -- Frontend lint + type-check + build -- Trivy + GitGuardian scan -- Snyk dependency audit -- Terraform fmt/validate/plan (no apply) -- Final `quality-gate` status - -## `staging.yml` - -Triggers: - -- successful `CI` workflow run on `develop` -- manual dispatch - -Responsibilities: - -- Build/push backend and frontend images to ECR -- Apply staging Terraform -- Force ECS rolling deploy for both services -- Run reusable smoke test against `vars.APP_URL` - -Config model: - -- Non-secret values loaded from `config/.env.staging` -- Secrets from GitHub Environment `staging` - -## `release.yml` - -Two flows: - -1. Semantic release flow - - Trigger: successful `CI` workflow run on `main` (or manual dispatch) - - Runs semantic-release (creates version tag/release) -2. Production deploy flow - - Trigger: tag push `v*` - - Builds/pushes images to ECR - - Runs Terraform apply for production - - Forces ECS deploy - - Runs smoke test against `vars.APP_URL` - -Config model: - -- Uses GitHub Environment `production` secrets - -## Required GitHub configuration - -### Repository secrets - -- `DATABASE_USER` -- `DATABASE_PASSWORD` -- `DATABASE_NAME` -- `DATABASE_PORT` -- `AWS_ROLE_TO_ASSUME` -- `GITGUARDIAN_API_KEY` -- `SNYK_TOKEN` - -### Environment `staging` secrets - -- `AWS_ROLE_TO_ASSUME` -- `TERRAFORM_STATE_BUCKET` -- `TERRAFORM_LOCK_TABLE` (compatibility input) -- `JWT_SECRET_KEY` - -### Environment `staging` vars - -- `APP_URL` - -### Environment `production` secrets - -- `AWS_ROLE_TO_ASSUME` -- `AWS_REGION` -- `TF_VERSION` -- `TERRAFORM_STATE_BUCKET` -- `TERRAFORM_LOCK_TABLE` (compatibility input) -- `JWT_SECRET_KEY` - -### Environment `production` vars - -- `APP_URL` - -## Terraform backend lock note - -Infrastructure init now uses `use_lockfile=true` for backend locking. - -`TERRAFORM_LOCK_TABLE` remains exposed in current workflow inputs for backward compatibility. - -## Related files - -- `.github/workflows/ci.yml` -- `.github/workflows/staging.yml` -- `.github/workflows/release.yml` -- `.github/workflows/_smoke-test.yml` diff --git a/.github/actions/terraform/apply/action.yml b/.github/actions/terraform/apply/action.yml deleted file mode 100644 index 0c3c9a9..0000000 --- a/.github/actions/terraform/apply/action.yml +++ /dev/null @@ -1,108 +0,0 @@ -############################################################################### -# composite action: terraform/apply -# -# Self-contained Terraform workflow: init (with remote S3 state backend) → -# plan → apply. Designed for trusted deployment jobs; never used on PRs. -# The environment name is normalised ("production" → "prod") before loading -# the matching envs/.tfvars file. -# -# Callers: staging.yml (terraform-staging), release.yml (terraform-production) -# Inputs: working-directory, terraform-version, environment, aws-region, -# state-bucket, state-lock-table -############################################################################### -name: "Terraform Apply" -description: "Init + plan + apply Terraform changes for a given environment" - -inputs: - working-directory: - description: "Terraform working directory" - required: false - default: "./infra" - terraform-version: - description: "Terraform version to install" - required: false - default: "1.5.0" - environment: - description: "Target environment (staging | prod | production)" - required: true - aws-region: - description: "AWS region" - required: false - default: "us-east-1" - state-bucket: - description: "S3 bucket for Terraform state" - required: true - state-lock-table: - description: "(Deprecated) DynamoDB table for state locking" - required: false - default: "" - -outputs: - apply-summary: - description: "Key Terraform outputs after apply" - value: ${{ steps.export.outputs.summary }} - -runs: - using: "composite" - steps: - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: ${{ inputs.terraform-version }} - - - name: Terraform init - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - # Normalise: 'production' -> 'prod' for tfvars file lookup - ENV="${{ inputs.environment }}" - [[ "$ENV" == "production" ]] && ENV="prod" - echo "TF_ENV=$ENV" >> "$GITHUB_ENV" - - terraform init \ - -backend-config="bucket=${{ inputs.state-bucket }}" \ - -backend-config="key=${{ inputs.environment }}/terraform.tfstate" \ - -backend-config="region=${{ inputs.aws-region }}" \ - -backend-config="use_lockfile=true" \ - -upgrade - echo "Terraform init complete" - - - name: Terraform plan - id: plan - shell: bash - working-directory: ${{ inputs.working-directory }} - env: - TF_VAR_environment: ${{ inputs.environment }} - TF_VAR_aws_region: ${{ inputs.aws-region }} - run: | - PLAN_FILE="/tmp/tfplan.${{ inputs.environment }}" - terraform plan \ - -var-file="envs/${TF_ENV}.tfvars" \ - -out="$PLAN_FILE" \ - -input=false \ - -no-color - echo "plan-file=$PLAN_FILE" >> "$GITHUB_OUTPUT" - echo "Terraform plan complete" - - - name: Terraform apply - id: apply - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - terraform apply -auto-approve "${{ steps.plan.outputs.plan-file }}" - echo "Terraform apply complete" - - - name: Export outputs - id: export - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - OUTPUT_FILE="/tmp/tf-outputs-${{ inputs.environment }}.json" - terraform output -json > "$OUTPUT_FILE" - echo "Outputs saved to: $OUTPUT_FILE" - - { - echo 'summary</dev/null || echo "(no outputs)" - echo 'EOF' - } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/terraform/plan/action.yml b/.github/actions/terraform/plan/action.yml deleted file mode 100644 index 30e023c..0000000 --- a/.github/actions/terraform/plan/action.yml +++ /dev/null @@ -1,108 +0,0 @@ -############################################################################### -# composite action: terraform/plan -# -# Runs terraform init (no backend) + terraform plan for a given environment -# and saves the plan output. Intended for review purposes only — never -# applies changes. -# -# Callers: ci.yml delegates to terraform/validate for the validate step, -# then runs an inline plan so output can be posted to PR comments. -# Inputs: working-directory, terraform-version, environment -############################################################################### -name: "Terraform Plan" -description: "Generate Terraform plan for infrastructure changes" - -inputs: - working-directory: - description: "Terraform working directory" - required: false - default: "./infra" - terraform-version: - description: "Terraform version" - required: false - default: "1.5.0" - environment: - description: "Environment (dev, staging, prod)" - required: true - aws-region: - description: "AWS region" - required: false - default: "us-east-1" - state-bucket: - description: "S3 bucket for Terraform state" - required: true - state-lock-table: - description: "(Deprecated) DynamoDB table for state locking" - required: false - default: "" - -outputs: - plan-summary: - description: "Terraform plan summary" - value: ${{ steps.plan.outputs.summary }} - -runs: - using: "composite" - steps: - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: ${{ inputs.terraform-version }} - - - name: Terraform init - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - terraform init \ - -backend-config="bucket=${{ inputs.state-bucket }}" \ - -backend-config="key=${{ inputs.environment }}/terraform.tfstate" \ - -backend-config="region=${{ inputs.aws-region }}" \ - -backend-config="use_lockfile=true" \ - -upgrade - - - name: Terraform plan - id: plan - shell: bash - working-directory: ${{ inputs.working-directory }} - env: - TF_VAR_environment: ${{ inputs.environment }} - TF_VAR_aws_region: ${{ inputs.aws-region }} - run: | - terraform plan \ - -var-file="envs/${{ inputs.environment }}.tfvars" \ - -out=tfplan.${{ inputs.environment }} \ - -no-color > plan-summary.txt 2>&1 - - # Output summary for step - { - echo 'summary<> $GITHUB_OUTPUT - - echo "✓ Terraform plan generated" - - - name: Save plan artifact - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - mkdir -p /tmp/tf-plans - cp tfplan.${{ inputs.environment }} /tmp/tf-plans/ - echo "Plan saved to: /tmp/tf-plans/tfplan.${{ inputs.environment }}" - - - name: Run Checkov IaC scan - uses: bridgecrewio/checkov-action@master - with: - directory: ${{ inputs.working-directory }} - framework: "terraform" - output_format: "sarif" - output_file_path: "checkov-tf-results.sarif" - quiet: true - continue-on-error: true - - - name: Upload Checkov results - uses: github/codeql-action/upload-sarif@v2 - with: - sarif_file: "checkov-tf-results.sarif" - category: "checkov-terraform" - continue-on-error: true diff --git a/.github/actions/terraform/validate/action.yml b/.github/actions/terraform/validate/action.yml deleted file mode 100644 index 9075f1b..0000000 --- a/.github/actions/terraform/validate/action.yml +++ /dev/null @@ -1,67 +0,0 @@ -############################################################################### -# composite action: terraform/validate -# -# Runs terraform fmt --check, terraform init -backend=false, and -# terraform validate to catch formatting and syntax errors without -# requiring remote state or AWS credentials. -# -# Callers: ci.yml (terraform-plan job, before the inline plan step) -# Inputs: working-directory, terraform-version -############################################################################### -name: "Terraform Validate" -description: "Validate Terraform configuration" - -inputs: - working-directory: - description: "Terraform working directory" - required: false - default: "./infra" - terraform-version: - description: "Terraform version" - required: false - default: "1.5.0" - -runs: - using: "composite" - steps: - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: ${{ inputs.terraform-version }} - - - name: Terraform format check - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - echo "Checking Terraform format..." - terraform fmt -check -recursive - echo "✓ Format check passed" - - - name: Terraform init - shell: bash - working-directory: ${{ inputs.working-directory }} - run: terraform init -backend=false - - - name: Terraform validate - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - echo "Validating Terraform configuration..." - terraform validate - echo "✓ Validation passed" - - - name: Setup tflint - uses: terraform-linters/setup-tflint@v3 - - - name: Initialize tflint - shell: bash - working-directory: ${{ inputs.working-directory }} - run: tflint --init - - - name: Run tflint - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - echo "Running tflint..." - tflint --format compact - echo "✓ tflint check passed" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c79794..579ef86 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,7 @@ version: 2 # Ecosystem Coverage: # ✓ Python (Poetry) - /backend # ✓ Docker (Base images) - /backend, /frontend -# ✓ Terraform (Providers/Modules) - /infra +# ✓ Terraform (Providers/Modules) - moved to infra repository # ✓ GitHub Actions (Workflow versions) - / # # CI Integration: @@ -130,47 +130,7 @@ updates: prefix: "build(docker):" include: "scope" - # ============================================================================ - # TERRAFORM: Infrastructure as Code - # ============================================================================ - # Manages AWS provider versions and Terraform modules - # - # Strategy: - # - Weekly updates (Tuesday 04:00 UTC) - # - Max 3 open PRs (moderate: requires infrastructure review) - # - All versions allowed (Terraform handles most upgrades) - # - Security-focused: Always on latest provider versions - # - # Why Terraform Updates? - # - AWS provider includes new resources and bug fixes - # - Terraform maintains compatibility across major versions - # - Infrastructure tests validate changes via CI - # - ECS Fargate deployment orchestrated through Terraform - # - # NOTE: Do NOT modify Terraform code directly - # - Dependabot only updates provider/module versions - # - Resource configurations remain unchanged - # - CI validates all infrastructure changes - # ============================================================================ - - package-ecosystem: "terraform" - directory: "/infra" - schedule: - interval: "weekly" - day: "tuesday" - time: "04:00" - open-pull-requests-limit: 3 - labels: - - "infra" - - "dependencies" - - "terraform" - pull-request-branch-name: - separator: "/" - commit-message: - prefix: "build(terraform):" - include: "scope" - # Allow major version updates for terraform to ensure latest provider support - allow: - - dependency-type: "all" + # Terraform dependency updates are managed in mypythonproject1-infra. # ============================================================================ # GITHUB ACTIONS: Workflow Versions diff --git a/.github/workflows/cd-ecs.yml b/.github/workflows/cd-ecs.yml new file mode 100644 index 0000000..ad525f0 --- /dev/null +++ b/.github/workflows/cd-ecs.yml @@ -0,0 +1,398 @@ +############################################################################### +# CD — Option 1: ECS / Fargate Deployment +############################################################################### +# +# Repository separation: +# mypythonproject1 ← app code + this workflow (YOU ARE HERE) +# mypythonproject1-infra ← Terraform for ECS/Fargate infrastructure +# +# Pipeline: +# CI passes on develop → build + push images (staging tag) → dispatch +# to infra repo to terraform-apply → ECS rolling +# deploy → smoke-test +# +# Tag v* is pushed → build + push images (semver + latest) → same +# sequence on production environment +# +# Secrets (GitHub Environments "staging" and "production"): +# AWS_ROLE_TO_ASSUME — OIDC role ARN; must have ECR + ECS permissions +# AWS_REGION — e.g. us-east-1 +# INFRA_DEPLOY_TOKEN — PAT with repo scope on mypythonproject1-infra +# (needed to trigger workflow_dispatch on infra repo) +# +# Variables (GitHub Environments): +# APP_URL — Base URL for smoke test health check +############################################################################### +name: "CD \u2014 ECS/Fargate" + +on: + workflow_run: + workflows: ["CI"] + branches: [develop] + types: [completed] + push: + tags: + - "v*" + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + type: choice + options: [staging, production] + default: staging + +permissions: + contents: read + +# Staging deploy: cancel-in-progress is fine (redeploy with latest commit). +# Production deploy: NEVER cancel mid-flight. +concurrency: + group: >- + ${{ startsWith(github.ref, 'refs/tags/v') && 'cd-ecs-production' + || github.event_name == 'workflow_dispatch' && format('cd-ecs-{0}', inputs.environment) + || 'cd-ecs-staging' }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} + +############################################################################### +# ─── STAGING ──────────────────────────────────────────────────────────────── +############################################################################### + + # --------------------------------------------------------------------------- + # 1a. Build + push staging images + # --------------------------------------------------------------------------- +jobs: + build-staging: + name: "Build & Push [staging] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + if: > + (github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'workflow_dispatch' + && inputs.environment == 'staging') + environment: staging + strategy: + matrix: + service: [backend, frontend] + fail-fast: true + permissions: + contents: read + id-token: write + outputs: + registry: ${{ steps.ecr-login.outputs.registry }} + image-backend: ${{ steps.meta.outputs.tags }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Authenticate with AWS + uses: ./.github/actions/aws-auth + with: + role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag & push + uses: ./.github/actions/docker-build + with: + context: ./${{ matrix.service }} + dockerfile: ./${{ matrix.service }}/Dockerfile + image-name: mypythonproject1/${{ matrix.service }} + registry: ${{ steps.ecr-login.outputs.registry }} + tags: | + type=raw,value=staging + type=sha,prefix=staging-,format=short + build-args: | + BUILD_ENV=staging + GIT_SHA=${{ github.event.workflow_run.head_sha || github.sha }} + platforms: linux/amd64 + scan: "true" + scan-severity: "CRITICAL,HIGH" + scan-exit-code: "0" + cache-scope: ${{ matrix.service }}-staging + + # --------------------------------------------------------------------------- + # 1b. Trigger Terraform apply on infra repo (staging) + # --------------------------------------------------------------------------- + infra-staging: + name: "Infra Apply [staging]" + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [build-staging] + environment: staging + steps: + - name: Dispatch terraform-apply to infra repo + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: 'mypythonproject1-infra', + workflow_id: 'terraform-apply.yml', + ref: 'main', + inputs: { + environment: 'staging', + image_tag: 'staging', + }, + }); + console.log('Dispatched terraform-apply on mypythonproject1-infra (staging)'); + + # Give Terraform apply ~10 min to complete before ECS deploy + - name: Wait for infra apply + run: | + echo "Waiting 10 minutes for Terraform apply to complete..." + sleep 600 + + # --------------------------------------------------------------------------- + # 1c. ECS rolling deploy (staging) + # --------------------------------------------------------------------------- + deploy-staging: + name: "ECS Deploy [staging] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [infra-staging] + environment: staging + strategy: + matrix: + service: [backend, frontend] + fail-fast: false + permissions: + id-token: write + steps: + - name: Authenticate with AWS + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Force new ECS deployment + run: | + CLUSTER="mypythonproject1-staging-cluster" + SERVICE="mypythonproject1-staging-${{ matrix.service }}-service" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --force-new-deployment \ + --region "${{ secrets.AWS_REGION }}" + + - name: Wait for service stability + timeout-minutes: 15 + run: | + CLUSTER="mypythonproject1-staging-cluster" + SERVICE="mypythonproject1-staging-${{ matrix.service }}-service" + aws ecs wait services-stable \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "${{ secrets.AWS_REGION }}" + + # --------------------------------------------------------------------------- + # 1d. Smoke test (staging) + # --------------------------------------------------------------------------- + smoke-test-staging: + name: "Smoke Test [staging]" + needs: [deploy-staging] + uses: ./.github/workflows/_smoke-test.yml + with: + environment: staging + app-url: ${{ vars.APP_URL }} + warmup-seconds: 15 + deploy-sha: ${{ github.event.workflow_run.head_sha || github.sha }} + secrets: inherit + +############################################################################### +# ─── PRODUCTION ───────────────────────────────────────────────────────────── +############################################################################### + + # --------------------------------------------------------------------------- + # 2a. Semantic release (tag creation) — only on workflow_run from main + # --------------------------------------------------------------------------- + autoversion: + name: "Semantic Release" + runs-on: ubuntu-latest + timeout-minutes: 10 + if: > + github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_branch == 'main' + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + ref: ${{ github.event.workflow_run.head_branch }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: .github/package-lock.json + + - name: Install semantic-release + working-directory: .github + run: npm ci --ignore-scripts + + - name: Run semantic-release + working-directory: .github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release + + # --------------------------------------------------------------------------- + # 2b. Build + push production images (blocking CRITICAL vulns) + # --------------------------------------------------------------------------- + build-production: + name: "Build & Push [production] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + if: startsWith(github.ref, 'refs/tags/v') + environment: production + strategy: + matrix: + service: [backend, frontend] + fail-fast: true + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Resolve version tag + id: ver + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Authenticate with AWS + uses: ./.github/actions/aws-auth + with: + role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag & push (CRITICAL blocks deploy) + uses: ./.github/actions/docker-build + with: + context: ./${{ matrix.service }} + dockerfile: ./${{ matrix.service }}/Dockerfile + image-name: mypythonproject1/${{ matrix.service }} + registry: ${{ steps.ecr-login.outputs.registry }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + type=sha,prefix=,format=short + build-args: | + BUILD_ENV=production + GIT_SHA=${{ steps.ver.outputs.sha_short }} + VERSION=${{ steps.ver.outputs.tag }} + platforms: linux/amd64 + scan: "true" + scan-severity: "CRITICAL" + scan-exit-code: "1" + cache-scope: ${{ matrix.service }}-production + + # --------------------------------------------------------------------------- + # 2c. Trigger Terraform apply on infra repo (production) + # --------------------------------------------------------------------------- + infra-production: + name: "Infra Apply [production]" + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [build-production] + environment: production + steps: + - name: Dispatch terraform-apply to infra repo + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const tag = context.ref.replace('refs/tags/', ''); + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: 'mypythonproject1-infra', + workflow_id: 'terraform-apply.yml', + ref: 'main', + inputs: { + environment: 'production', + image_tag: tag, + }, + }); + console.log(`Dispatched terraform-apply on mypythonproject1-infra (production, tag=${tag})`); + + - name: Wait for infra apply + run: | + echo "Waiting 15 minutes for Terraform apply to complete..." + sleep 900 + + # --------------------------------------------------------------------------- + # 2d. ECS rolling deploy (production) + # --------------------------------------------------------------------------- + deploy-production: + name: "ECS Deploy [production] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [infra-production] + environment: production + strategy: + matrix: + service: [backend, frontend] + fail-fast: false + permissions: + id-token: write + steps: + - name: Authenticate with AWS + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Force new ECS deployment + run: | + CLUSTER="mypythonproject1-production-cluster" + SERVICE="mypythonproject1-production-${{ matrix.service }}-service" + aws ecs update-service \ + --cluster "$CLUSTER" \ + --service "$SERVICE" \ + --force-new-deployment \ + --region "${{ secrets.AWS_REGION }}" + + - name: Wait for service stability + timeout-minutes: 15 + run: | + CLUSTER="mypythonproject1-production-cluster" + SERVICE="mypythonproject1-production-${{ matrix.service }}-service" + aws ecs wait services-stable \ + --cluster "$CLUSTER" \ + --services "$SERVICE" \ + --region "${{ secrets.AWS_REGION }}" + + # --------------------------------------------------------------------------- + # 2e. Smoke test (production) + # --------------------------------------------------------------------------- + smoke-test-production: + name: "Smoke Test [production]" + needs: [deploy-production] + uses: ./.github/workflows/_smoke-test.yml + with: + environment: production + app-url: ${{ vars.APP_URL }} + warmup-seconds: 20 + deploy-version: ${{ github.ref_name }} + deploy-sha: ${{ github.sha }} + secrets: inherit diff --git a/.github/workflows/cd-eks-gitops.yml b/.github/workflows/cd-eks-gitops.yml new file mode 100644 index 0000000..52eed7d --- /dev/null +++ b/.github/workflows/cd-eks-gitops.yml @@ -0,0 +1,562 @@ +############################################################################### +# CD — Option 2: EKS / ArgoCD GitOps Deployment +############################################################################### +# +# Repository separation (three-repo GitOps model): +# mypythonproject1 ← app code + this workflow (YOU ARE HERE) +# mypythonproject1-infra2 ← Terraform for EKS infrastructure +# mypythonproject1-gitops ← Helm charts + ArgoCD Application manifests +# +# Pipeline: +# ┌─────────────────────────────────────────────────────────────────┐ +# │ 1. CI passes on develop / tag v* │ +# │ 2. Build & push image to ECR (digest pinned) │ +# │ 3. Update image tag in gitops repo (environments// │ +# │ values.yaml) via a commit to main │ +# │ 4. ArgoCD detects the commit and auto-syncs (App-of-Apps) │ +# │ 5. Smoke test via ArgoCD health status poll │ +# └─────────────────────────────────────────────────────────────────┘ +# +# This workflow does NOT run Terraform — infrastructure is managed by a +# dedicated pipeline in mypythonproject1-infra2. +# +# Secrets (GitHub Environments "staging" and "production"): +# AWS_ROLE_TO_ASSUME — OIDC role ARN with ECR push permission +# AWS_REGION — e.g. us-east-1 +# GITOPS_DEPLOY_TOKEN — PAT (contents:write) on mypythonproject1-gitops +# ARGOCD_SERVER — ArgoCD server hostname (no https://) +# ARGOCD_TOKEN — ArgoCD API token for health poll +# +# Variables (GitHub Environments): +# APP_URL — Base URL for smoke test health check +############################################################################### +name: "CD \u2014 EKS/ArgoCD GitOps" + +on: + workflow_run: + workflows: ["CI"] + branches: [develop] + types: [completed] + push: + tags: + - "v*" + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + type: choice + options: [staging, production] + default: staging + +permissions: + contents: read + +concurrency: + group: >- + ${{ startsWith(github.ref, 'refs/tags/v') && 'cd-gitops-production' + || github.event_name == 'workflow_dispatch' && format('cd-gitops-{0}', inputs.environment) + || 'cd-gitops-staging' }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} + +############################################################################### +# ─── DEV ──────────────────────────────────────────────────────────────────── +############################################################################### + +jobs: + # --------------------------------------------------------------------------- + # 0a. Build + push dev images (every CI success on develop) + # --------------------------------------------------------------------------- + build-dev: + name: "Build & Push [dev] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + if: > + (github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'workflow_dispatch' + && inputs.environment == 'staging') + environment: staging + strategy: + matrix: + service: [backend, frontend] + fail-fast: true + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Compute short SHA + id: meta + run: | + SHA="${{ github.event.workflow_run.head_sha || github.sha }}" + echo "sha-short=${SHA:0:7}" >> "$GITHUB_OUTPUT" + + - name: Authenticate with AWS + uses: ./.github/actions/aws-auth + with: + role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag & push + id: build + uses: ./.github/actions/docker-build + with: + context: ./${{ matrix.service }} + dockerfile: ./${{ matrix.service }}/Dockerfile + image-name: mypythonproject1/${{ matrix.service }} + registry: ${{ steps.ecr-login.outputs.registry }} + tags: | + type=raw,value=dev + type=sha,prefix=dev-,format=short + build-args: | + BUILD_ENV=dev + GIT_SHA=${{ github.event.workflow_run.head_sha || github.sha }} + platforms: linux/amd64 + scan: "true" + scan-severity: "CRITICAL" + scan-exit-code: "0" + cache-scope: ${{ matrix.service }}-dev + + # --------------------------------------------------------------------------- + # 0b. Update gitops repo — dev values (fast, no approval gate) + # --------------------------------------------------------------------------- + update-gitops-dev: + name: "Update GitOps Repo [dev]" + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [build-dev] + environment: staging + permissions: + contents: read + steps: + - name: Checkout gitops repo + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/python-angular-project1-gitops + token: ${{ secrets.GITOPS_DEPLOY_TOKEN }} + path: gitops + + - name: Compute new image tag + id: tag + run: | + SHA="${{ github.event.workflow_run.head_sha || github.sha }}" + echo "new-tag=dev-${SHA:0:7}" >> "$GITHUB_OUTPUT" + + - name: Patch dev values — backend + frontend + run: | + FILE="gitops/environments/dev/values.yaml" + docker run --rm -v "$PWD/gitops:/gitops" mikefarah/yq:4 \ + e '.backend.image.tag = "${{ steps.tag.outputs.new-tag }}" | .frontend.image.tag = "${{ steps.tag.outputs.new-tag }}"' \ + -i /gitops/environments/dev/values.yaml + + - name: Commit & push to gitops repo + run: | + cd gitops + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add environments/dev/values.yaml + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore(deploy): dev image tag ${{ steps.tag.outputs.new-tag }} [skip ci]" + git push origin main + +############################################################################### +# ─── STAGING ──────────────────────────────────────────────────────────────── +############################################################################### + # --------------------------------------------------------------------------- + # 1a. Build + push staging images to ECR; output digest for immutable ref + # --------------------------------------------------------------------------- + build-staging: + name: "Build & Push [staging] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + if: > + (github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'workflow_dispatch' + && inputs.environment == 'staging') + environment: staging + strategy: + matrix: + service: [backend, frontend] + fail-fast: true + permissions: + contents: read + id-token: write + outputs: + # Each matrix leg writes its own digest; consumed by update-gitops-staging + backend-digest: ${{ steps.digest.outputs.backend-digest }} + frontend-digest: ${{ steps.digest.outputs.frontend-digest }} + sha-short: ${{ steps.meta.outputs.sha-short }} + registry: ${{ steps.ecr-login.outputs.registry }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Compute short SHA + id: meta + run: | + SHA="${{ github.event.workflow_run.head_sha || github.sha }}" + echo "sha-short=${SHA:0:7}" >> "$GITHUB_OUTPUT" + + - name: Authenticate with AWS + uses: ./.github/actions/aws-auth + with: + role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag & push + id: build + uses: ./.github/actions/docker-build + with: + context: ./${{ matrix.service }} + dockerfile: ./${{ matrix.service }}/Dockerfile + image-name: mypythonproject1/${{ matrix.service }} + registry: ${{ steps.ecr-login.outputs.registry }} + tags: | + type=raw,value=staging + type=sha,prefix=staging-,format=short + build-args: | + BUILD_ENV=staging + GIT_SHA=${{ github.event.workflow_run.head_sha || github.sha }} + platforms: linux/amd64 + scan: "true" + scan-severity: "CRITICAL,HIGH" + scan-exit-code: "0" + cache-scope: ${{ matrix.service }}-staging + + - name: Export image digest + id: digest + run: | + echo "${{ matrix.service }}-digest=${{ steps.build.outputs.image-digest }}" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 1b. Update image tags in gitops repo (staging values) + # --------------------------------------------------------------------------- + update-gitops-staging: + name: "Update GitOps Repo [staging]" + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [build-staging] + environment: staging + permissions: + contents: read + steps: + - name: Checkout gitops repo + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/python-angular-project1-gitops + token: ${{ secrets.GITOPS_DEPLOY_TOKEN }} + path: gitops + + - name: Compute new image tag + id: tag + run: | + SHA="${{ github.event.workflow_run.head_sha || github.sha }}" + echo "new-tag=staging-${SHA:0:7}" >> "$GITHUB_OUTPUT" + + - name: Patch staging values — backend + run: | + FILE="gitops/environments/staging/values.yaml" + # Use yq to update only the image.tag fields — avoids sed fragility + docker run --rm -v "$PWD/gitops:/gitops" \ + mikefarah/yq:4 \ + e '.backend.image.tag = "${{ steps.tag.outputs.new-tag }}"' \ + -i /gitops/environments/staging/values.yaml + echo "Backend image.tag patched to ${{ steps.tag.outputs.new-tag }}" + + - name: Patch staging values — frontend + run: | + docker run --rm -v "$PWD/gitops:/gitops" \ + mikefarah/yq:4 \ + e '.frontend.image.tag = "${{ steps.tag.outputs.new-tag }}"' \ + -i /gitops/environments/staging/values.yaml + echo "Frontend image.tag patched to ${{ steps.tag.outputs.new-tag }}" + + - name: Commit & push to gitops repo + run: | + cd gitops + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add environments/staging/values.yaml + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore(deploy): staging image tag ${{ steps.tag.outputs.new-tag }} [skip ci]" + git push origin main + + # --------------------------------------------------------------------------- + # 1c. Wait for ArgoCD to sync staging and report health + # --------------------------------------------------------------------------- + argocd-sync-staging: + name: "ArgoCD Sync [staging]" + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [update-gitops-staging] + environment: staging + steps: + - name: Poll ArgoCD application health + env: + ARGOCD_SERVER: ${{ secrets.ARGOCD_SERVER }} + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + run: | + APP="mypythonproject1-staging" + MAX=30 # 30 × 30s = 15 min + SLEEP=30 + + for i in $(seq 1 $MAX); do + RESP=$(curl -sf \ + -H "Authorization: Bearer $ARGOCD_TOKEN" \ + "https://${ARGOCD_SERVER}/api/v1/applications/${APP}" || true) + + HEALTH=$(echo "$RESP" | jq -r '.status.health.status // "Unknown"') + SYNC=$(echo "$RESP" | jq -r '.status.sync.status // "Unknown"') + echo "[$i/$MAX] health=$HEALTH sync=$SYNC" + + if [[ "$HEALTH" == "Healthy" && "$SYNC" == "Synced" ]]; then + echo "✅ ArgoCD: $APP is Healthy and Synced" + exit 0 + fi + + if [[ "$HEALTH" == "Degraded" ]]; then + echo "❌ ArgoCD: $APP health is Degraded — failing fast" + exit 1 + fi + + sleep $SLEEP + done + + echo "❌ Timeout: $APP did not become Healthy+Synced within $((MAX * SLEEP / 60)) min" + exit 1 + + # --------------------------------------------------------------------------- + # 1d. Smoke test (staging) + # --------------------------------------------------------------------------- + smoke-test-staging: + name: "Smoke Test [staging]" + needs: [argocd-sync-staging] + uses: ./.github/workflows/_smoke-test.yml + with: + environment: staging + app-url: ${{ vars.APP_URL }} + warmup-seconds: 15 + deploy-sha: ${{ github.event.workflow_run.head_sha || github.sha }} + secrets: inherit + +############################################################################### +# ─── PRODUCTION ───────────────────────────────────────────────────────────── +############################################################################### + + # --------------------------------------------------------------------------- + # 2a. Semantic release on CI success from main → creates tag + # --------------------------------------------------------------------------- + autoversion: + name: "Semantic Release" + runs-on: ubuntu-latest + timeout-minutes: 10 + if: > + github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_branch == 'main' + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + ref: main + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: .github/package-lock.json + + - name: Install semantic-release + working-directory: .github + run: npm ci --ignore-scripts + + - name: Run semantic-release + working-directory: .github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release + + # --------------------------------------------------------------------------- + # 2b. Build + push production images (CRITICAL vulns block) + # --------------------------------------------------------------------------- + build-production: + name: "Build & Push [production] (${{ matrix.service }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + if: startsWith(github.ref, 'refs/tags/v') + environment: production + strategy: + matrix: + service: [backend, frontend] + fail-fast: true + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Resolve version tag + id: ver + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Authenticate with AWS + uses: ./.github/actions/aws-auth + with: + role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag & push (CRITICAL blocks deploy) + uses: ./.github/actions/docker-build + with: + context: ./${{ matrix.service }} + dockerfile: ./${{ matrix.service }}/Dockerfile + image-name: mypythonproject1/${{ matrix.service }} + registry: ${{ steps.ecr-login.outputs.registry }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + type=sha,prefix=,format=short + build-args: | + BUILD_ENV=production + GIT_SHA=${{ steps.ver.outputs.sha_short }} + VERSION=${{ steps.ver.outputs.tag }} + platforms: linux/amd64 + scan: "true" + scan-severity: "CRITICAL" + scan-exit-code: "1" + cache-scope: ${{ matrix.service }}-production + + # --------------------------------------------------------------------------- + # 2c. Update gitops repo — production values + # --------------------------------------------------------------------------- + update-gitops-production: + name: "Update GitOps Repo [production]" + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [build-production] + environment: production + permissions: + contents: read + steps: + - name: Checkout gitops repo + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/python-angular-project1-gitops + token: ${{ secrets.GITOPS_DEPLOY_TOKEN }} + path: gitops + + - name: Resolve version tag + id: ver + run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Patch production values — backend + run: | + docker run --rm -v "$PWD/gitops:/gitops" \ + mikefarah/yq:4 \ + e '.backend.image.tag = "${{ steps.ver.outputs.tag }}"' \ + -i /gitops/environments/production/values.yaml + + - name: Patch production values — frontend + run: | + docker run --rm -v "$PWD/gitops:/gitops" \ + mikefarah/yq:4 \ + e '.frontend.image.tag = "${{ steps.ver.outputs.tag }}"' \ + -i /gitops/environments/production/values.yaml + + - name: Commit & push to gitops repo + run: | + cd gitops + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add environments/production/values.yaml + git diff --cached --quiet && echo "No changes" && exit 0 + git commit -m "chore(deploy): production image tag ${{ steps.ver.outputs.tag }} [skip ci]" + git push origin main + + # --------------------------------------------------------------------------- + # 2d. ArgoCD sync + health poll (production) + # --------------------------------------------------------------------------- + argocd-sync-production: + name: "ArgoCD Sync [production]" + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [update-gitops-production] + environment: production + steps: + - name: Poll ArgoCD application health + env: + ARGOCD_SERVER: ${{ secrets.ARGOCD_SERVER }} + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + run: | + APP="mypythonproject1-production" + MAX=40 + SLEEP=30 + + for i in $(seq 1 $MAX); do + RESP=$(curl -sf \ + -H "Authorization: Bearer $ARGOCD_TOKEN" \ + "https://${ARGOCD_SERVER}/api/v1/applications/${APP}" || true) + + HEALTH=$(echo "$RESP" | jq -r '.status.health.status // "Unknown"') + SYNC=$(echo "$RESP" | jq -r '.status.sync.status // "Unknown"') + echo "[$i/$MAX] health=$HEALTH sync=$SYNC" + + if [[ "$HEALTH" == "Healthy" && "$SYNC" == "Synced" ]]; then + echo "✅ ArgoCD: $APP is Healthy and Synced" + exit 0 + fi + + if [[ "$HEALTH" == "Degraded" ]]; then + echo "❌ ArgoCD: $APP health is Degraded — failing fast" + exit 1 + fi + + sleep $SLEEP + done + + echo "❌ Timeout: $APP did not become Healthy+Synced within $((MAX * SLEEP / 60)) min" + exit 1 + + # --------------------------------------------------------------------------- + # 2e. Smoke test (production) + # --------------------------------------------------------------------------- + smoke-test-production: + name: "Smoke Test [production]" + needs: [argocd-sync-production] + uses: ./.github/workflows/_smoke-test.yml + with: + environment: production + app-url: ${{ vars.APP_URL }} + warmup-seconds: 20 + deploy-version: ${{ github.ref_name }} + deploy-sha: ${{ github.sha }} + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7f6278..89ed225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,10 @@ # • Dependency vulnerability audit (Snyk) # • Secret scanning (GitGuardian) # • Filesystem vulnerability scan (Trivy) -# • Terraform fmt + validate + plan ← plan ONLY, never apply # • quality-gate job required by branch protection rules # -# NO deployment logic lives here. +# NO deployment and NO infrastructure logic lives here. +# Infrastructure is managed in a separate repo (mypythonproject1-infra or -infra2). # # Triggers: # • pull_request → main, develop @@ -63,7 +63,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" - name: Install commitlint @@ -86,7 +86,6 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} - infra: ${{ steps.filter.outputs.infra }} ci: ${{ steps.filter.outputs.ci }} steps: - uses: actions/checkout@v4 @@ -99,8 +98,6 @@ jobs: - 'backend/**' frontend: - 'frontend/**' - infra: - - 'infra/**' ci: - '.github/**' - 'package.json' @@ -127,7 +124,7 @@ jobs: POSTGRES_USER: ${{ secrets.DATABASE_USER }} POSTGRES_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} POSTGRES_DB: ${{ secrets.DATABASE_NAME }} - POSTGRES_PORT: ${{ secrets.DATABASE_PORT }} + POSTGRES_PORT: ${{ vars.POSTGRES_PORT }} services: postgres: image: postgres:16-alpine @@ -141,7 +138,7 @@ jobs: --health-timeout 5s --health-retries 5 ports: - - ${{ env.POSTGRES_PORT }}:5432 + - "${{ vars.POSTGRES_PORT }}:5432" steps: @@ -152,6 +149,7 @@ jobs: # CI tooling vars (TF_VERSION, REGISTRY) from config/.env.test run: | grep -v '^\s*#' config/.env.test | grep -v '^\s*$' >> $GITHUB_ENV + echo "POSTGRES_PORT=${{ vars.POSTGRES_PORT }}" >> $GITHUB_ENV - uses: actions/setup-python@v5 with: @@ -234,7 +232,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: frontend/package-lock.json @@ -337,79 +335,7 @@ jobs: continue-on-error: true # ========================================================================= - # 7. TERRAFORM PLAN ← PR only, NEVER applies - # ========================================================================= - terraform-plan: - name: Terraform Plan (${{ matrix.environment }}) - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [changes] - if: | - (needs.changes.outputs.infra == 'true' || needs.changes.outputs.ci == 'true') && - github.event_name == 'pull_request' - strategy: - matrix: - environment: [staging, prod] - max-parallel: 1 - fail-fast: false - permissions: - contents: read - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v4 - - - name: Load CI environment - run: | - grep -v '^\s*#' config/.env.test | grep -v '^\s*$' >> $GITHUB_ENV - - - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: ${{ env.TF_VERSION }} - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ env.AWS_REGION }} - - - name: Terraform fmt check - working-directory: infra - run: terraform fmt -check -recursive - - - name: Terraform init (no remote state — validation only) - working-directory: infra - run: terraform init -backend=false - - - name: Terraform validate - working-directory: infra - run: terraform validate - - - name: Terraform plan - id: plan - working-directory: infra - env: - TF_VAR_environment: ${{ matrix.environment }} - TF_VAR_aws_region: ${{ env.AWS_REGION }} - run: | - terraform plan \ - -var-file="envs/${{ matrix.environment }}.tfvars" \ - -no-color 2>&1 | tee plan-output.txt - - - name: Post plan to PR - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const plan = fs.readFileSync('infra/plan-output.txt', 'utf8').substring(0, 65000); - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, repo: context.repo.repo, - body: `## 🔍 Terraform Plan — \`${{ matrix.environment }}\`\n\`\`\`hcl\n${plan}\n\`\`\`` - }); - - # ========================================================================= - # 8. QUALITY GATE — single status required by branch protection + # 7. QUALITY GATE — single status required by branch protection # ========================================================================= quality-gate: name: Quality Gate @@ -422,31 +348,23 @@ jobs: - frontend-ci - security-scan - dependency-audit - - terraform-plan steps: - name: Evaluate gate run: | CL="${{ needs.commitlint.result }}" BE="${{ needs.backend-ci.result }}" FE="${{ needs.frontend-ci.result }}" - TF="${{ needs.terraform-plan.result }}" echo "commitlint: $CL" echo "backend-ci: $BE" echo "frontend-ci: $FE" - echo "tf-plan: $TF" - # Hard failures — must pass for r in "$BE" "$FE"; do [[ "$r" == "failure" ]] && { echo "❌ Required job failed"; exit 1; } done - # Commitlint fails only on PRs (skipped on push) [[ "$CL" == "failure" ]] && { echo "❌ Commitlint failed"; exit 1; } - # Terraform plan failure blocks PRs when infra changed - [[ "$TF" == "failure" ]] && { echo "❌ Terraform plan failed"; exit 1; } - echo "✅ All gates passed" - name: Write step summary @@ -462,7 +380,6 @@ jobs: echo "| Frontend CI | $(icon ${{ needs.frontend-ci.result }}) \`${{ needs.frontend-ci.result }}\` |" echo "| Security Scan | $(icon ${{ needs.security-scan.result }}) \`${{ needs.security-scan.result }}\` |" echo "| Dependency Audit | $(icon ${{ needs.dependency-audit.result }}) \`${{ needs.dependency-audit.result }}\` |" - echo "| Terraform Plan | $(icon ${{ needs.terraform-plan.result }}) \`${{ needs.terraform-plan.result }}\` |" echo "" echo "_\`${{ github.event_name }}\` on \`${{ github.ref_name }}\` — \`${{ github.sha }}\`_" } >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c248e73..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,241 +0,0 @@ -############################################################################### -# CD — Release & Production Deployment -############################################################################### -# Two distinct flows gated by trigger type: -# -# Flow 1 — Semantic Release (workflow_run: CI succeeds on main) -# Runs semantic-release: analyses conventional commits since last tag, -# bumps version, writes CHANGELOG.md, creates git tag + GitHub Release. -# Does NOT deploy — the tag creation triggers Flow 2. -# -# Flow 2 — Production Deploy (push tag v*) -# a. Build production images tagged vX.Y.Z + latest → ECR -# b. Trivy image scan (CRITICAL blocks deploy) -# c. Terraform apply — production environment -# d. ECS deploy — production environment -# e. Smoke test -# -# Concurrency: production deploy is NEVER cancelled mid-flight. -# ALL secrets come from the "production" GitHub Environment. -############################################################################### -name: CD — Release & Production - -on: - workflow_run: - workflows: ["CI"] - branches: [main] - types: [completed] - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "Tag to deploy (e.g. v1.2.3) — leave empty for latest" - required: false - type: string - -permissions: - contents: read - -concurrency: - group: ${{ startsWith(github.ref, 'refs/tags/') && 'production-deploy' || 'semantic-release' }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} - -jobs: - autoversion: - name: Semantic Release - runs-on: ubuntu-latest - timeout-minutes: 10 - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') - permissions: - contents: write - issues: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: true - ref: ${{ github.event.workflow_run.head_branch || 'main' }} - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: .github/package-lock.json - - - name: Install semantic-release - working-directory: .github - run: npm ci --ignore-scripts - - - name: Run semantic-release - working-directory: .github - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release - - build-production: - name: Build & Push (${{ matrix.service }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - if: startsWith(github.ref, 'refs/tags/v') - environment: production - strategy: - matrix: - service: [backend, frontend] - fail-fast: true - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Resolve version tag - id: ver - run: | - TAG="${GITHUB_REF#refs/tags/}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - - - name: Authenticate with AWS - id: aws - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: Log in to Amazon ECR - id: ecr-login - uses: aws-actions/amazon-ecr-login@v2 - - - name: Build, push & scan (blocking on CRITICAL) - uses: ./.github/actions/docker-build - with: - context: ./${{ matrix.service }} - dockerfile: ./${{ matrix.service }}/Dockerfile - image-name: mypythonproject1/${{ matrix.service }} - registry: ${{ steps.ecr-login.outputs.registry }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - type=sha,prefix=,format=short - build-args: | - BUILD_ENV=production - GIT_SHA=${{ steps.ver.outputs.sha_short }} - VERSION=${{ steps.ver.outputs.tag }} - platforms: linux/amd64 - scan: "true" - scan-severity: "CRITICAL" - scan-exit-code: "1" - cache-scope: ${{ matrix.service }}-prod - - terraform-production: - name: Terraform Apply (production) - runs-on: ubuntu-latest - timeout-minutes: 30 - if: startsWith(github.ref, 'refs/tags/v') - needs: [build-production] - environment: production - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Authenticate with AWS - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: Set Terraform runtime vars - run: | - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) - REGION="${{ secrets.AWS_REGION }}" - echo "TF_VAR_ecr_repository_url=${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/mypythonproject1/backend" >> "$GITHUB_ENV" - echo "TF_VAR_frontend_ecr_repository_url=${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/mypythonproject1/frontend" >> "$GITHUB_ENV" - echo "TF_VAR_jwt_secret_key=${{ secrets.JWT_SECRET_KEY }}" >> "$GITHUB_ENV" - - - name: Terraform apply - uses: ./.github/actions/terraform/apply - with: - working-directory: ./infra - terraform-version: ${{ secrets.TF_VERSION }} - environment: production - aws-region: ${{ secrets.AWS_REGION }} - state-bucket: ${{ secrets.TERRAFORM_STATE_BUCKET }} - state-lock-table: ${{ secrets.TERRAFORM_LOCK_TABLE }} - - deploy-production: - name: Deploy to Production (${{ matrix.service }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - if: startsWith(github.ref, 'refs/tags/v') - needs: [terraform-production] - environment: production - strategy: - matrix: - service: [backend, frontend] - fail-fast: false - permissions: - id-token: write - steps: - - uses: actions/checkout@v4 - - - name: Authenticate with AWS - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: Force new ECS deployment - run: | - PROJECT_NAME=$(awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$2); print $2; exit}' infra/envs/prod.tfvars) - CLUSTER_NAME="${PROJECT_NAME}-cluster" - if [[ "${{ matrix.service }}" == "backend" ]]; then - SERVICE_NAME="${PROJECT_NAME}-service" - else - SERVICE_NAME="${PROJECT_NAME}-frontend-service" - fi - - aws ecs update-service \ - --cluster "${CLUSTER_NAME}" \ - --service "${SERVICE_NAME}" \ - --force-new-deployment \ - --region "${{ secrets.AWS_REGION }}" - - - name: Wait for service stability - timeout-minutes: 15 - run: | - PROJECT_NAME=$(awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$2); print $2; exit}' infra/envs/prod.tfvars) - CLUSTER_NAME="${PROJECT_NAME}-cluster" - if [[ "${{ matrix.service }}" == "backend" ]]; then - SERVICE_NAME="${PROJECT_NAME}-service" - else - SERVICE_NAME="${PROJECT_NAME}-frontend-service" - fi - - aws ecs wait services-stable \ - --cluster "${CLUSTER_NAME}" \ - --services "${SERVICE_NAME}" \ - --region "${{ secrets.AWS_REGION }}" - - smoke-test-production: - name: Smoke Test - needs: [deploy-production] - uses: ./.github/workflows/_smoke-test.yml - with: - environment: production - app-url: ${{ vars.APP_URL }} - warmup-seconds: 20 - deploy-version: ${{ github.ref_name }} - deploy-sha: ${{ github.sha }} - secrets: inherit diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml deleted file mode 100644 index 2f5bcd3..0000000 --- a/.github/workflows/staging.yml +++ /dev/null @@ -1,194 +0,0 @@ -############################################################################### -# CD — Staging Deployment -############################################################################### -# Responsibilities: -# - Build + push backend + frontend images to GHCR (Trivy scan, warn-only) -# - Terraform apply for staging environment -# - ECS rolling deploy (force-new-deployment, always pulls the 'staging' tag) -# - Smoke test after deployment -# -# Triggers: -# - workflow_run: CI succeeds on develop -# - workflow_dispatch (manual re-deploy) -# -# ALL secrets come from the GitHub Environment "staging". -# Non-secret config is loaded from config/.env.staging. -############################################################################### -name: CD — Staging - -on: - workflow_run: - workflows: ["CI"] - branches: [develop] - types: [completed] - workflow_dispatch: - inputs: - force-deploy: - description: "Force deploy even if no code changes" - required: false - type: boolean - default: false - -permissions: - contents: read - packages: write - id-token: write - -concurrency: - group: staging-deploy - cancel-in-progress: true - -jobs: - build: - name: Build & Push (${{ matrix.service }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') - environment: staging - strategy: - matrix: - service: [backend, frontend] - fail-fast: true - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - - name: Load staging environment - run: grep -v '^\s*#' config/.env.staging | grep -v '^\s*$' >> "$GITHUB_ENV" - - - name: Authenticate with AWS - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ env.AWS_REGION }} - - - name: Log in to Amazon ECR - id: ecr-login - uses: aws-actions/amazon-ecr-login@v2 - - - name: Build, push & scan - uses: ./.github/actions/docker-build - with: - context: ./${{ matrix.service }} - dockerfile: ./${{ matrix.service }}/Dockerfile - image-name: mypythonproject1/${{ matrix.service }} - registry: ${{ steps.ecr-login.outputs.registry }} - tags: | - type=raw,value=staging - type=sha,prefix=staging-,format=short - build-args: | - BUILD_ENV=staging - GIT_SHA=${{ github.event.workflow_run.head_sha || github.sha }} - platforms: linux/amd64 - scan: "true" - scan-severity: "CRITICAL,HIGH" - scan-exit-code: "0" - cache-scope: ${{ matrix.service }}-staging - - terraform-staging: - name: Terraform Apply (staging) - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [build] - environment: staging - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - - name: Load staging environment - run: grep -v '^\s*#' config/.env.staging | grep -v '^\s*$' >> "$GITHUB_ENV" - - - name: Authenticate with AWS - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ env.AWS_REGION }} - - - name: Set Terraform runtime vars - run: | - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) - echo "TF_VAR_ecr_repository_url=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mypythonproject1/backend" >> "$GITHUB_ENV" - echo "TF_VAR_frontend_ecr_repository_url=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mypythonproject1/frontend" >> "$GITHUB_ENV" - echo "TF_VAR_jwt_secret_key=${{ secrets.JWT_SECRET_KEY }}" >> "$GITHUB_ENV" - - - name: Terraform apply - uses: ./.github/actions/terraform/apply - with: - working-directory: ./infra - terraform-version: ${{ env.TF_VERSION }} - environment: staging - aws-region: ${{ env.AWS_REGION }} - state-bucket: ${{ secrets.TERRAFORM_STATE_BUCKET }} - state-lock-table: ${{ secrets.TERRAFORM_LOCK_TABLE }} - - deploy-staging: - name: Deploy to Staging (${{ matrix.service }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [terraform-staging] - environment: staging - strategy: - matrix: - service: [backend, frontend] - fail-fast: false - permissions: - id-token: write - steps: - - uses: actions/checkout@v4 - - - name: Load staging environment - run: grep -v '^\s*#' config/.env.staging | grep -v '^\s*$' >> "$GITHUB_ENV" - - - name: Authenticate with AWS - uses: ./.github/actions/aws-auth - with: - role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ env.AWS_REGION }} - - - name: Force new ECS deployment - run: | - PROJECT_NAME=$(awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$2); print $2; exit}' infra/envs/staging.tfvars) - CLUSTER_NAME="${PROJECT_NAME}-cluster" - if [[ "${{ matrix.service }}" == "backend" ]]; then - SERVICE_NAME="${PROJECT_NAME}-service" - else - SERVICE_NAME="${PROJECT_NAME}-frontend-service" - fi - - aws ecs update-service \ - --cluster "${CLUSTER_NAME}" \ - --service "${SERVICE_NAME}" \ - --force-new-deployment \ - --region "${{ env.AWS_REGION }}" - - - name: Wait for service stability - timeout-minutes: 10 - run: | - PROJECT_NAME=$(awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$2); print $2; exit}' infra/envs/staging.tfvars) - CLUSTER_NAME="${PROJECT_NAME}-cluster" - if [[ "${{ matrix.service }}" == "backend" ]]; then - SERVICE_NAME="${PROJECT_NAME}-service" - else - SERVICE_NAME="${PROJECT_NAME}-frontend-service" - fi - - aws ecs wait services-stable \ - --cluster "${CLUSTER_NAME}" \ - --services "${SERVICE_NAME}" \ - --region "${{ env.AWS_REGION }}" - - smoke-test: - name: Smoke Test - needs: [deploy-staging] - uses: ./.github/workflows/_smoke-test.yml - with: - environment: staging - app-url: ${{ vars.APP_URL }} - warmup-seconds: 15 - deploy-sha: ${{ github.event.workflow_run.head_sha || github.sha }} - secrets: inherit diff --git a/Makefile b/Makefile index 6fa0409..d83d65f 100644 --- a/Makefile +++ b/Makefile @@ -36,14 +36,15 @@ NC := \033[0m # No Color # ============================================================================ ENV_FILE := deploy/.env -DEV_TFVARS_FILE := infra/envs/dev.tfvars +INFRA_ROOT ?= ../mypythonproject1-infra +DEV_TFVARS_FILE := $(INFRA_ROOT)/environments/dev/terraform.tfvars -include $(ENV_FILE) # Prefer tfvars as source of truth for infra variables. TFVARS_ENV := $(shell awk -F'=' '/^environment[[:space:]]*=/{gsub(/["[:space:]]/,"",$$2); print $$2; exit}' $(DEV_TFVARS_FILE) 2>/dev/null) ENV ?= $(or $(TFVARS_ENV),dev) -TFVARS_FILE = infra/envs/$(ENV).tfvars +TFVARS_FILE = $(INFRA_ROOT)/environments/$(ENV)/terraform.tfvars TFVARS_PROJECT_NAME := $(shell awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$$2); print $$2; exit}' $(TFVARS_FILE) 2>/dev/null || awk -F'=' '/^project_name[[:space:]]*=/{gsub(/["[:space:]]/,"",$$2); print $$2; exit}' $(DEV_TFVARS_FILE) 2>/dev/null) TFVARS_AWS_REGION := $(shell awk -F'=' '/^aws_region[[:space:]]*=/{gsub(/["[:space:]]/,"",$$2); print $$2; exit}' $(TFVARS_FILE) 2>/dev/null || awk -F'=' '/^aws_region[[:space:]]*=/{gsub(/["[:space:]]/,"",$$2); print $$2; exit}' $(DEV_TFVARS_FILE) 2>/dev/null) @@ -102,12 +103,6 @@ help: @echo " $(YELLOW)make bootstrap$(NC) - Terraform bootstrap (ECR, IAM, S3, optional DynamoDB lock table)" @echo " $(YELLOW)make setup-env$(NC) - Export env vars for Terraform/deploy (ENV=staging|prod|dev)" @echo "" - @echo "$(GREEN)🏗️ TERRAFORM$(NC)" - @echo " $(YELLOW)make tf-validate$(NC) - Validate Terraform" - @echo " $(YELLOW)make tf-plan$(NC) - Plan infrastructure (ENV=dev|staging|prod)" - @echo " $(YELLOW)make tf-apply$(NC) - Apply infrastructure (ENV=dev|staging|prod)" - @echo " $(YELLOW)make tf-destroy$(NC) - Destroy infrastructure (ENV=dev|staging|prod)" - @echo "" @echo "$(GREEN)🐳 DOCKER BUILD & PUSH$(NC)" @echo " $(YELLOW)make docker-build$(NC) - Build Docker images (ENV=staging|prod)" @echo " $(YELLOW)make docker-push$(NC) - Push to ECR (ENV=staging|prod)" @@ -268,37 +263,6 @@ frontend-build: cd frontend && npm run build @echo "$(GREEN)✅ Frontend build complete!$(NC)" -# ============================================================================ -# ONE-TIME BOOTSTRAP & ENV SETUP -# ============================================================================ - -bootstrap: - @echo "$(GREEN)🚀 Bootstrapping AWS infrastructure (one-time setup)...$(NC)" - @bash scripts/bootstrap.sh - -setup-env: - @echo "$(GREEN)📝 Setting up environment for ENV=$(ENV)...$(NC)" - @ENV=$(ENV) AWS_REGION=$(AWS_REGION) TERRAFORM_STATE_BUCKET=$(TERRAFORM_STATE_BUCKET) TERRAFORM_LOCK_TABLE=$(TERRAFORM_LOCK_TABLE) bash scripts/setup-env.sh - -# ============================================================================ -# TERRAFORM OPERATIONS -# ============================================================================ - -tf-validate: - @echo "$(GREEN)📋 Validating Terraform...$(NC)" - @bash scripts/terraform-validate.sh - -tf-plan: - @echo "$(GREEN)📋 Planning Terraform for ENV=$(ENV)...$(NC)" - @ENV=$(ENV) AWS_REGION=$(AWS_REGION) TERRAFORM_STATE_BUCKET=$(TERRAFORM_STATE_BUCKET) TERRAFORM_LOCK_TABLE=$(TERRAFORM_LOCK_TABLE) bash scripts/terraform-plan.sh - -tf-apply: - @echo "$(GREEN)🚀 Applying Terraform for ENV=$(ENV)...$(NC)" - @ENV=$(ENV) AWS_REGION=$(AWS_REGION) TERRAFORM_STATE_BUCKET=$(TERRAFORM_STATE_BUCKET) TERRAFORM_LOCK_TABLE=$(TERRAFORM_LOCK_TABLE) bash scripts/terraform-apply.sh - -tf-destroy: - @echo "$(RED)⚠️ Destroying Terraform infrastructure for ENV=$(ENV)...$(NC)" - @ENV=$(ENV) AWS_REGION=$(AWS_REGION) TERRAFORM_STATE_BUCKET=$(TERRAFORM_STATE_BUCKET) TERRAFORM_LOCK_TABLE=$(TERRAFORM_LOCK_TABLE) bash scripts/terraform-destroy.sh # ============================================================================ # DOCKER BUILD & PUSH @@ -322,49 +286,6 @@ docker-push: docker push $(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com/mypythonproject1/frontend:$(ENV) @echo "$(GREEN)✅ Images pushed to ECR$(NC)" -# ============================================================================ -# ECS DEPLOYMENT -# ============================================================================ - -ecs-deploy: - @if [ -z "$(ENV)" ] || [ -z "$(IMAGE_TAG)" ]; then \ - echo "$(RED)❌ Missing parameters. Usage: make ecs-deploy ENV=staging IMAGE_TAG=sha-abc123$(NC)"; exit 1; fi - @echo "$(GREEN)🚀 Deploying to ECS (cluster=$(ECS_CLUSTER), ENV=$(ENV), IMAGE_TAG=$(IMAGE_TAG))...$(NC)" - aws ecs update-service \ - --cluster $(ECS_CLUSTER) \ - --service $(ECS_SERVICE_BACKEND) \ - --force-new-deployment \ - --region $(AWS_REGION) - aws ecs update-service \ - --cluster $(ECS_CLUSTER) \ - --service $(ECS_SERVICE_FRONTEND) \ - --force-new-deployment \ - --region $(AWS_REGION) - @echo "$(GREEN)✅ ECS deployment triggered (run 'make setup-env ENV=$(ENV)' to verify cluster/service names)$(NC)" - -# ============================================================================ -# FULL DEPLOYMENT ORCHESTRATION -# ============================================================================ - -deploy: - @if [ -z "$(ENV)" ] || [ -z "$(IMAGE_TAG)" ]; then \ - echo "$(RED)❌ Missing parameters. Usage: make deploy ENV=staging IMAGE_TAG=sha-abc123$(NC)"; exit 1; fi - @echo "$(GREEN)🚀 Full deployment starting (ENV=$(ENV), IMAGE_TAG=$(IMAGE_TAG))...$(NC)" - @$(MAKE) docker-build ENV=$(ENV) - @$(MAKE) docker-push ENV=$(ENV) - @$(MAKE) ecs-deploy ENV=$(ENV) IMAGE_TAG=$(IMAGE_TAG) - @echo "$(GREEN)✅ Deployment complete!$(NC)" - -deploy-staging: - @if [ -z "$(IMAGE_TAG)" ]; then echo "$(RED)❌ IMAGE_TAG not set. Usage: make deploy-staging IMAGE_TAG=...$(NC)"; exit 1; fi - @echo "$(GREEN)🚀 Deploying to STAGING (ENV=staging, IMAGE_TAG=$(IMAGE_TAG))...$(NC)" - @$(MAKE) deploy ENV=staging IMAGE_TAG=$(IMAGE_TAG) - -deploy-prod: - @if [ -z "$(IMAGE_TAG)" ]; then echo "$(RED)❌ IMAGE_TAG not set. Usage: make deploy-prod IMAGE_TAG=...$(NC)"; exit 1; fi - @echo "$(RED)🚀 Deploying to PRODUCTION (ENV=prod, IMAGE_TAG=$(IMAGE_TAG))...$(NC)" - @$(MAKE) deploy ENV=prod IMAGE_TAG=$(IMAGE_TAG) - # ============================================================================ # CLEANUP # ============================================================================ diff --git a/README.md b/README.md index aa5f62e..8e42f76 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,115 @@ -# MyPythonProject1 - -Production-ready full-stack app with FastAPI + Angular + PostgreSQL on AWS ECS Fargate, provisioned by Terraform and delivered through GitHub Actions. - -## High-level architecture - -- Frontend: Angular app served by Nginx in ECS Fargate -- Backend: FastAPI service in ECS Fargate -- Database: PostgreSQL on AWS RDS -- Networking: ALB + VPC (public/private/db subnets) -- Runtime secrets: AWS Secrets Manager -- Infra state: S3 backend with native lockfile locking (`use_lockfile=true`) -- Images: Amazon ECR for both staging and production deploy flows - -## Repository structure - -```text -. -├── backend/ # FastAPI service + alembic + tests -├── frontend/ # Angular application -├── infra/ # Terraform root + modules + env tfvars -├── deploy/ # docker-compose local stack -├── config/ # env templates and ops guides -├── docs/ # architecture / onboarding / testing docs -└── .github/ # workflows and composite CI/CD actions -``` - -## Local development - -### Prerequisites - -- Docker Desktop -- Python 3.12+ -- Poetry -- Node.js 20+ -- Make - -### Start full stack - -```bash -make install -cp config/.env.dev deploy/.env -docker compose -f deploy/docker-compose.yml up --build -``` - -URLs: - -- Frontend: http://localhost:4200 -- Backend: http://localhost:8000 -- API docs: http://localhost:8000/docs -- Health: http://localhost:8000/health - -## Testing - -```bash -make test -make backend-test -make frontend-test -``` - -Backend split: - -```bash -cd backend -poetry run pytest tests/unit -m unit -v -poetry run pytest tests/integration -m integration -v -``` - -## CI/CD - -Detailed reference: `.github/GITHUB_ACTIONS_CICD.md` - -- `ci.yml` - - Trigger: PR/push on `main` and `develop` - - Runs lint/tests/security/dependency audit + Terraform fmt/validate/plan - - Does not deploy -- `staging.yml` - - Trigger: successful CI workflow on `develop` or manual dispatch - - Builds/pushes backend+frontend images to ECR - - Applies Terraform for staging - - Forces ECS rollout and runs smoke test -- `release.yml` - - Flow 1: successful CI on `main` runs semantic-release - - Flow 2: `v*` tag builds/pushes ECR images, applies prod Terraform, deploys ECS, runs smoke test - -## Environment configuration - -Non-secret configuration files: - -- `config/.env.dev` (local docker compose) -- `config/.env.test` (tests/CI) -- `config/.env.staging` (staging workflow runtime config) -- `config/.env.production` (reference values) - -Operational docs: - -- `config/environment-setup.md` -- `config/secrets-management.md` - -## Manual infrastructure commands - -From repository root: - -```bash -make tf-validate ENV=staging -make tf-plan ENV=staging -make tf-apply ENV=staging -``` - -Destroy (destructive): - -```bash -make tf-destroy ENV=staging -``` - -## AWS bootstrap - -At minimum, create: - -1. S3 bucket for Terraform state -2. GitHub OIDC provider in IAM -3. IAM roles assumed by GitHub Actions environments (`staging`, `production`) -4. ECR repositories for backend/frontend images - -Run bootstrap: - -```bash -make bootstrap -``` - -Optional inputs: - -```bash -GITHUB_ORG= GITHUB_REPO= AWS_REGION=us-east-1 make bootstrap -``` - -See `infra/README.md` for full bootstrap and IAM guidance. - -## Versioning - -- Commits follow Conventional Commits -- `release.yml` uses semantic-release to generate version tags and release notes -- Production deploys are triggered by semantic tags (`v*`) - -## Common commands - -```bash -make help -make lint -make format -make backend -make frontend -make dev -``` +Project Composition +Enterprise Multi-Infrastructure DevOps Project + +1. Project Composition + +Application Stack (shared across all infra projects) + • Backend: Python (FastAPI) + • Frontend: Angular + • Database: PostgreSQL (AWS RDS) + • Containerization: Docker + • Environments: Dev, Staging, Prod + +Deployment Strategies / Infra Repos + +Repo / Infra Compute Deployment CI/CD IaC +platform-infra-fargate ECS Fargate Terraform GitHub Actions Terraform modules +platform-infra-ec2 EC2 + ASG Ansible (provision) + Terraform GitHub Actions Terraform + Ansible +platform-infra-eks EKS GitOps (ArgoCD + Helm) GitHub Actions → ArgoCD Terraform modules + Helm charts + + +⸻ + +1. Architecture Overview + +2.1 Shared Components + • Networking: VPC per infra, multi-AZ subnets, NAT gateway, private/public segregation + • IAM: Least privilege, separate roles per CI/CD, per service, per environment + • Logging & Monitoring: CloudWatch for all infra; Prometheus + Grafana for EKS + • Security: Encrypted RDS/ECR, security groups per service, CloudTrail & GuardDuty + +2.2 Fargate Infra + + +-------------------------+ + | ALB | + +-----------+-------------+ + | + +----------+----------+ + | ECS Cluster (Fargate)| + +----+----------+-----+ + | | | | + Backend Frontend Workers # optional + | | | + ECR ECR ECR + | + CloudWatch logs + | + RDS (private) + +2.3 EC2 + Ansible Infra + + +----------------------+ + | ALB | + +-----------+----------+ + | + +---------+----------+ + | EC2 ASG (Backend) | + | EC2 ASG (Frontend)| + +---------+----------+ + | + Ansible Playbooks + | + CloudWatch Logs + | + RDS (private) + +2.4 EKS + ArgoCD + Helm Infra + +Root App (ArgoCD) + ├── Non-Prod Cluster (EKS) + │ ├── Namespace: dev + │ │ ├── backend + │ │ └── frontend + │ └── Namespace: staging + │ ├── backend + │ └── frontend + └── Prod Cluster (EKS) + └── Namespace: prod + ├── backend + └── frontend + | + Helm charts + | + RDS (private) + + • App-of-Apps pattern for dev/staging/prod + • Manual approval for prod + • Prometheus + Grafana + CloudWatch for monitoring + +⸻ + +3. Terraform Modules (per repo) + +Common modules + • vpc/ → VPC, subnets, NAT, route tables, security groups + • iam/ → Roles for CI/CD, service accounts, ECS/EKS nodes + • ecr/ → Docker repos for backend/frontend + • rds/ → PostgreSQL with encryption, backups, multi-AZ + • security/ → SGs, NACLs, private endpoints + +Infra-specific modules + • Fargate: ecs-cluster/, ecs-service/, alb/ + • EC2: ec2/, alb/ + • EKS: eks/, helm-charts/ (values per env) + +⸻ + +4. CI/CD Strategy + +Infra Pipeline Flow +Fargate Build Docker → Push ECR → Terraform apply ECS → CloudWatch logs +EC2 Build Docker → Push ECR → Ansible deploy → CloudWatch logs +EKS Build Docker → Push ECR → Update Helm values → ArgoCD auto-sync (dev/staging) / manual sync (prod) → Prometheus + Grafana metrics + + • Speculative Terraform plan before merge + • Manual approval for prod deployments \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 43900dd..0000000 --- a/backend/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Backend - -FastAPI backend with SQLAlchemy, Alembic, JWT auth, and pytest. - -## Local setup - -```bash -cd backend -poetry install -``` - -From project root (recommended): - -```bash -cp config/.env.dev deploy/.env -docker compose -f deploy/docker-compose.yml up postgres backend --build -``` - -Endpoints: - -- API: http://localhost:8000 -- Swagger: http://localhost:8000/docs -- Health: http://localhost:8000/health - -## Migrations - -```bash -cd backend -poetry run alembic upgrade head -poetry run alembic revision --autogenerate -m "describe change" -``` - -## Testing - -```bash -cd backend -poetry run pytest -poetry run pytest tests/unit -m unit -v -poetry run pytest tests/integration -m integration -v -``` - -Coverage: - -```bash -poetry run pytest --cov=app --cov-report=html -open htmlcov/index.html -``` - -## Configuration - -Configuration is loaded from environment variables via `app/core/config.py`. - -Core variables: - -- `ENVIRONMENT` -- `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` -- `DATABASE_PASSWORD` (secret) -- `JWT_SECRET_KEY` (secret) -- `JWT_ALGORITHM` - -In AWS, secrets come from AWS Secrets Manager through Terraform-provisioned runtime wiring. - -## Structure - -```text -backend/ -├── app/ -│ ├── api/ -│ ├── core/ -│ ├── db/ -│ ├── models/ -│ ├── schemas/ -│ └── services/ -├── alembic/ -├── tests/ -└── pyproject.toml -``` - -## CI/CD notes - -- `ci.yml`: lint + unit/integration tests -- `staging.yml` and `release.yml`: build backend image and push to ECR diff --git a/config/environment-setup.md b/config/environment-setup.md index d313370..30f303e 100644 --- a/config/environment-setup.md +++ b/config/environment-setup.md @@ -5,8 +5,8 @@ How local, staging, and production environments are configured and promoted. ## Environment types - Local: `config/.env.dev` copied to `deploy/.env` -- Staging: `infra/envs/staging.tfvars` + GitHub Environment `staging` -- Production: `infra/envs/prod.tfvars` + GitHub Environment `production` +- Staging: `../mypythonproject1-infra/environments/staging/terraform.tfvars` + GitHub Environment `staging` +- Production: `../mypythonproject1-infra/environments/prod/terraform.tfvars` + GitHub Environment `production` ## Local development @@ -24,7 +24,7 @@ open http://localhost:4200 ## Staging setup -1. Configure `infra/envs/staging.tfvars` +1. Configure `../mypythonproject1-infra/environments/staging/terraform.tfvars` 2. Configure GitHub Environment `staging` secrets: - `AWS_ROLE_TO_ASSUME` - `TERRAFORM_STATE_BUCKET` @@ -42,7 +42,7 @@ Deploy flow: ## Production setup -1. Configure `infra/envs/prod.tfvars` +1. Configure `../mypythonproject1-infra/environments/prod/terraform.tfvars` 2. Configure GitHub Environment `production` secrets: - `AWS_ROLE_TO_ASSUME` - `AWS_REGION` diff --git a/config/terraform.tfvars.example b/config/terraform.tfvars.example index 70b998f..133e771 100644 --- a/config/terraform.tfvars.example +++ b/config/terraform.tfvars.example @@ -1,6 +1,6 @@ # ============================================================================== # Terraform Variables Example -# Copy to: infra/envs/{env}.tfvars +# Copy to: ../mypythonproject1-infra/environments/{env}/terraform.tfvars # ============================================================================== # ============================================================================ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 88b158f..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,82 +0,0 @@ -# Architecture - -System architecture for application runtime, infrastructure, and delivery flow. - -## Runtime topology - -```text -Internet - -> ALB (public) - -> ECS Fargate services (private subnets) - - backend (FastAPI) - - frontend (Nginx + Angular) - -> RDS PostgreSQL (db subnets) - -Supporting services: - - AWS Secrets Manager (runtime secrets) - - Amazon ECR (container images) - - S3 backend state + lockfile locking (Terraform) - - CloudWatch Logs -``` - -## Application layers - -Backend (`backend/app`): - -- `api/`: route handlers -- `services/`: business logic -- `db/`: SQLAlchemy engine/session -- `models/` + `schemas/`: persistence and API contracts - -Frontend (`frontend/src/app`): - -- `components/`: UI features -- `services/`: API/auth/game/user clients -- `core/`: route guards and HTTP interceptor - -## CI/CD flow - -```text -feature/* -> PR -> develop/main - -CI (`ci.yml`) - - lint/test/security/dependency checks - - terraform fmt/validate/plan - -Staging CD (`staging.yml`) - - triggered by successful CI on develop (or manual) - - build/push backend+frontend images to ECR - - terraform apply (staging) - - ECS force deployment - - smoke test - -Release + Production CD (`release.yml`) - - successful CI on main -> semantic-release - - release tag `v*` -> production deploy flow - - build/push images to ECR - - terraform apply (production) - - ECS force deployment - - smoke test -``` - -## Secrets and configuration model - -- Runtime app secrets: AWS Secrets Manager -- CI/CD orchestration secrets: GitHub repository/environment secrets -- Non-secret pipeline config: environment files under `config/` - -No static AWS credentials are stored in repository files. GitHub Actions uses OIDC to assume AWS roles. - -## Terraform backend model - -Terraform remote state is stored in S3 and uses `use_lockfile=true` for locking. - -Example init: - -```bash -terraform init \ - -backend-config="bucket=" \ - -backend-config="key=staging/terraform.tfstate" \ - -backend-config="region=" \ - -backend-config="use_lockfile=true" -``` diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md deleted file mode 100644 index 22936d6..0000000 --- a/docs/ONBOARDING.md +++ /dev/null @@ -1,104 +0,0 @@ -# Onboarding Guide - -New developer path from clone to first successful PR. - -## Prerequisites - -- Git -- Docker Desktop -- Python 3.12+ -- Poetry -- Node.js 20+ -- Terraform 1.5+ -- AWS CLI v2 (for infra/deploy work) - -## Step 1 — Clone and install - -```bash -git clone https://github.com/your-org/mypythonproject1.git -cd mypythonproject1 -make install -``` - -## Step 2 — Configure local environment - -```bash -cp config/.env.dev deploy/.env -``` - -This project keeps local non-secret defaults in `config/.env.dev`. - -## Step 3 — Start local stack - -```bash -docker compose -f deploy/docker-compose.yml up --build -``` - -Verify: - -- Frontend: http://localhost:4200 -- Backend docs: http://localhost:8000/docs -- Health: http://localhost:8000/health - -## Step 4 — Run tests - -```bash -make test -``` - -Backend test split: - -```bash -cd backend -poetry run pytest tests/unit -m unit -v -poetry run pytest tests/integration -m integration -v -``` - -## Step 5 — Branch and commit rules - -Branch strategy: - -- `main`: production -- `develop`: integration branch -- `feature/*`: work branches from `develop` - -Conventional commit format is required: - -```text -feat(scope): add feature -fix(scope): fix behavior -docs: update docs -``` - -## Step 6 — Open PR to `develop` - -CI (`ci.yml`) runs lint/tests/security/dependency checks and Terraform plan checks. - -Primary required status: `quality-gate`. - -## Step 7 — Deployment behavior after merge - -- Merge to `develop` + successful CI -> `staging.yml` deploys to staging -- Merge to `main` + successful CI -> `release.yml` runs semantic-release -- Tag `v*` -> production deployment flow in `release.yml` - -Both staging and production deployment flows build/push images to Amazon ECR. - -## Common commands - -```bash -make dev -make lint -make format -make backend -make frontend -make help -``` - -## Related docs - -- `README.md` -- `docs/ARCHITECTURE.md` -- `docs/TEST_ARCHITECTURE.md` -- `infra/README.md` -- `.github/GITHUB_ACTIONS_CICD.md` diff --git a/docs/TEST_ARCHITECTURE.md b/docs/TEST_ARCHITECTURE.md deleted file mode 100644 index e55204b..0000000 --- a/docs/TEST_ARCHITECTURE.md +++ /dev/null @@ -1,50 +0,0 @@ -# Test Architecture - -Backend testing strategy and execution model. - -## Test layers - -```text -tests/ -├── unit/ # fully mocked; no real DB/network -└── integration/ # real DB-backed API/repository tests -``` - -## Running tests - -```bash -cd backend -poetry run pytest -poetry run pytest tests/unit -m unit -v -poetry run pytest tests/integration -m integration -v -poetry run pytest --cov=app --cov-report=html --cov-report=term-missing -``` - -## Markers - -Defined in `backend/pytest.ini`: - -- `unit` -- `integration` - -## Fixture strategy - -Key fixtures in `backend/tests/conftest.py`: - -- session-level test engine -- per-test transactional DB session rollback isolation -- async HTTP client fixture with dependency override -- auth/token helper fixtures for protected endpoint tests - -## Isolation model - -Integration tests run in per-test transactions and roll back automatically to keep state clean between tests. - -## CI behavior - -`ci.yml` runs backend tests in dedicated steps: - -- Unit tests -- Integration tests - -Both are part of the required CI quality gate. diff --git a/infra/.gitignore b/infra/.gitignore deleted file mode 100644 index 14c82b0..0000000 --- a/infra/.gitignore +++ /dev/null @@ -1,65 +0,0 @@ -# ============================================================================ -# Infrastructure/Terraform .gitignore -# ============================================================================ -# CRITICAL: Never commit .tfstate or terraform.tfvars -# ============================================================================ - -# ============================================================================ -# Environment & Secrets -# ============================================================================ -.env -.env.local -.env.*.local -*.tfvars -*.tfvars.json -!example.tfvars -!*.tfvars.example - -# ============================================================================ -# Terraform State -# ============================================================================ -.terraform/ -.terraform.lock.hcl -terraform.tfstate -terraform.tfstate.backup -terraform.tfstate.*.backup -*_terraform.tfstate - -# ============================================================================ -# Terraform Overrides -# ============================================================================ -override.tf -override.tf.json -*_override.tf -*_override.tf.json -crash.log -crash.*.log - -# ============================================================================ -# Plans & Logs -# ============================================================================ -*.tfplan -.terraformrc -terraform.log -*.log - -# ============================================================================ -# IDE -# ============================================================================ -.idea/ -.vscode/ -*.code-workspace - -# ============================================================================ -# OS -# ============================================================================ -.DS_Store -Thumbs.db - -# ============================================================================ -# Temp & Artifacts -# ============================================================================ -*.swp -*.swo -*.bak -.tmp/ \ No newline at end of file diff --git a/infra/README.md b/infra/README.md deleted file mode 100644 index 4a38e77..0000000 --- a/infra/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# Infrastructure - -Terraform infrastructure for AWS networking, compute, database, and deployment prerequisites. - -## Provisioned resources - -- VPC + public/private/db subnets -- ALB and listeners -- ECS cluster/services for backend and frontend -- RDS PostgreSQL -- IAM roles/policies needed by workloads - -## Layout - -```text -infra/ -├── main.tf -├── variables.tf -├── outputs.tf -├── providers.tf -├── backend-config.hcl -├── envs/ -│ ├── dev.tfvars -│ ├── staging.tfvars -│ └── prod.tfvars -└── modules/ - ├── vpc/ - ├── network/ - ├── alb/ - ├── ecs/ - └── rds/ -``` - -## Backend state model - -Terraform uses S3 remote state with native lockfile locking. - -`backend-config.hcl`: - -```hcl -bucket = "myproject-terraform-state" -key = "terraform.tfstate" -region = "us-east-1" -encrypt = true -use_lockfile = true -``` - -Manual init example: - -```bash -cd infra -terraform init \ - -backend-config="bucket=myproject-terraform-state" \ - -backend-config="key=staging/terraform.tfstate" \ - -backend-config="region=us-east-1" \ - -backend-config="use_lockfile=true" -``` - -Notes: - -- `dynamodb_table` is deprecated and no longer required. -- CI composite actions still accept a lock-table input for compatibility, but lockfile locking is the active mechanism. - -## First-time bootstrap (per AWS account) - -1. Create S3 bucket for Terraform state (versioned + encrypted) -2. Create GitHub OIDC provider in IAM -3. Create IAM roles trusted for GitHub Environments (`staging`, `production`) -4. Create ECR repositories for backend/frontend - -Recommended bootstrap path in this repo: - -```bash -make bootstrap -``` - -This runs the dedicated stack in `infra/bootstrap`. - -## Local usage - -```bash -make tf-validate ENV=staging -make tf-plan ENV=staging -make tf-apply ENV=staging -``` - -Destroy: - -```bash -make tf-destroy ENV=staging -``` - -Direct Terraform: - -```bash -cd infra -terraform fmt -check -recursive -terraform init -backend=false -terraform validate -terraform plan -var-file="envs/staging.tfvars" -``` - -## CI/CD behavior - -- `ci.yml`: fmt/validate/plan only (read-only) -- `staging.yml`: Terraform apply with staging values -- `release.yml`: Terraform apply with production values after release tag - -## Environment files - -- `envs/staging.tfvars`: staging sizing/capacity -- `envs/prod.tfvars`: production sizing/capacity -- `envs/dev.tfvars`: developer/shared lower-cost setup - -Note for `dev`: ECS desired counts are intentionally set to `0` so first-time `terraform apply` succeeds even before ECR images are pushed. After pushing images, scale up by setting `desired_count` / `frontend_desired_count` (and `min_capacity`) above `0`. - -Keep shared structure in modules and only vary environment inputs in tfvars. diff --git a/infra/backend-config.hcl b/infra/backend-config.hcl deleted file mode 100644 index c4fe70a..0000000 --- a/infra/backend-config.hcl +++ /dev/null @@ -1,5 +0,0 @@ -bucket = "terraform-state-767397670484" -key = "terraform/dev/terraform.tfstate" -region = "us-east-1" -use_lockfile = true -encrypt = true diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf deleted file mode 100644 index 2a6a24b..0000000 --- a/infra/bootstrap/main.tf +++ /dev/null @@ -1,195 +0,0 @@ -data "aws_caller_identity" "current" {} - -data "aws_partition" "current" {} - -locals { - account_id = data.aws_caller_identity.current.account_id - effective_bucket = var.state_bucket_name != "" ? var.state_bucket_name : "terraform-state-${local.account_id}" - backend_repo_name = "${var.project_name}/backend" - frontend_repo_name = "${var.project_name}/frontend" -} - -resource "aws_s3_bucket" "terraform_state" { - bucket = local.effective_bucket - - tags = { - Name = local.effective_bucket - Purpose = "terraform-state" - } -} - -resource "aws_s3_bucket_versioning" "terraform_state" { - bucket = aws_s3_bucket.terraform_state.id - - versioning_configuration { - status = "Enabled" - } -} - -resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { - bucket = aws_s3_bucket.terraform_state.id - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} - -resource "aws_s3_bucket_public_access_block" "terraform_state" { - bucket = aws_s3_bucket.terraform_state.id - - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true -} - -resource "aws_dynamodb_table" "terraform_lock" { - count = var.create_lock_table ? 1 : 0 - name = var.lock_table_name - billing_mode = "PAY_PER_REQUEST" - hash_key = "LockID" - - attribute { - name = "LockID" - type = "S" - } - - tags = { - Name = var.lock_table_name - Purpose = "terraform-lock-legacy" - } -} - -resource "aws_ecr_repository" "backend" { - name = local.backend_repo_name - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } - - encryption_configuration { - encryption_type = "AES256" - } -} - -resource "aws_ecr_repository" "frontend" { - name = local.frontend_repo_name - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } - - encryption_configuration { - encryption_type = "AES256" - } -} - -resource "aws_iam_openid_connect_provider" "github" { - url = "https://token.actions.githubusercontent.com" - client_id_list = ["sts.amazonaws.com"] - thumbprint_list = var.oidc_thumbprints -} - -data "aws_iam_policy_document" "github_actions_trust" { - statement { - effect = "Allow" - - actions = ["sts:AssumeRoleWithWebIdentity"] - - principals { - type = "Federated" - identifiers = [aws_iam_openid_connect_provider.github.arn] - } - - condition { - test = "StringEquals" - variable = "token.actions.githubusercontent.com:aud" - values = ["sts.amazonaws.com"] - } - - condition { - test = "StringLike" - variable = "token.actions.githubusercontent.com:sub" - values = [ - for env in var.github_environments : - "repo:${var.github_org}/${var.github_repo}:environment:${env}" - ] - } - } -} - -resource "aws_iam_role" "github_actions" { - name = var.github_actions_role_name - assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json -} - -data "aws_iam_policy_document" "github_actions_permissions" { - statement { - sid = "ECR" - effect = "Allow" - actions = ["ecr:*"] - resources = [ - aws_ecr_repository.backend.arn, - aws_ecr_repository.frontend.arn, - "arn:${data.aws_partition.current.partition}:ecr:${var.aws_region}:${local.account_id}:repository/${local.backend_repo_name}", - "arn:${data.aws_partition.current.partition}:ecr:${var.aws_region}:${local.account_id}:repository/${local.frontend_repo_name}" - ] - } - - statement { - sid = "ECRAuth" - effect = "Allow" - actions = ["ecr:GetAuthorizationToken"] - resources = ["*"] - } - - statement { - sid = "ECSAndInfraDeploy" - effect = "Allow" - actions = [ - "ecs:*", - "ec2:*", - "elasticloadbalancing:*", - "logs:*", - "cloudwatch:*", - "secretsmanager:*", - "kms:*", - "rds:*" - ] - resources = ["*"] - } - - statement { - sid = "StateBucket" - effect = "Allow" - actions = ["s3:*"] - resources = [ - aws_s3_bucket.terraform_state.arn, - "${aws_s3_bucket.terraform_state.arn}/*" - ] - } - - statement { - sid = "LegacyLockTable" - effect = "Allow" - actions = ["dynamodb:*"] - resources = var.create_lock_table ? [aws_dynamodb_table.terraform_lock[0].arn] : ["*"] - } - - statement { - sid = "PassRole" - effect = "Allow" - actions = ["iam:PassRole", "iam:GetRole", "iam:CreateServiceLinkedRole"] - resources = ["*"] - } -} - -resource "aws_iam_role_policy" "github_actions" { - name = "GitHubActionsPolicy" - role = aws_iam_role.github_actions.id - policy = data.aws_iam_policy_document.github_actions_permissions.json -} diff --git a/infra/bootstrap/outputs.tf b/infra/bootstrap/outputs.tf deleted file mode 100644 index d975d61..0000000 --- a/infra/bootstrap/outputs.tf +++ /dev/null @@ -1,34 +0,0 @@ -output "aws_account_id" { - description = "AWS account id" - value = data.aws_caller_identity.current.account_id -} - -output "aws_region" { - description = "AWS region used by bootstrap" - value = var.aws_region -} - -output "terraform_state_bucket" { - description = "Terraform remote state bucket" - value = aws_s3_bucket.terraform_state.bucket -} - -output "terraform_lock_table" { - description = "Legacy lock table name (empty when disabled)" - value = var.create_lock_table ? aws_dynamodb_table.terraform_lock[0].name : "" -} - -output "github_actions_role_arn" { - description = "Role ARN for GitHub Actions OIDC" - value = aws_iam_role.github_actions.arn -} - -output "backend_ecr_repository_url" { - description = "Backend ECR repository URL" - value = aws_ecr_repository.backend.repository_url -} - -output "frontend_ecr_repository_url" { - description = "Frontend ECR repository URL" - value = aws_ecr_repository.frontend.repository_url -} diff --git a/infra/bootstrap/providers.tf b/infra/bootstrap/providers.tf deleted file mode 100644 index 441b7bf..0000000 --- a/infra/bootstrap/providers.tf +++ /dev/null @@ -1,14 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } -} - -provider "aws" { - region = var.aws_region -} diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf deleted file mode 100644 index c95e6bc..0000000 --- a/infra/bootstrap/variables.tf +++ /dev/null @@ -1,57 +0,0 @@ -variable "aws_region" { - description = "AWS region for bootstrap resources" - type = string - default = "us-east-1" -} - -variable "project_name" { - description = "Project name prefix" - type = string - default = "mypythonproject1" -} - -variable "github_org" { - description = "GitHub organization/user name" - type = string -} - -variable "github_repo" { - description = "GitHub repository name" - type = string -} - -variable "github_actions_role_name" { - description = "IAM role name assumed by GitHub Actions via OIDC" - type = string - default = "GitHubActionsRole" -} - -variable "github_environments" { - description = "Allowed GitHub Environments that can assume this role" - type = list(string) - default = ["staging", "production"] -} - -variable "state_bucket_name" { - description = "Override for Terraform state bucket name; empty uses terraform-state-" - type = string - default = "" -} - -variable "lock_table_name" { - description = "DynamoDB lock table name (legacy compatibility)" - type = string - default = "terraform-locks" -} - -variable "create_lock_table" { - description = "Create DynamoDB lock table for backward compatibility" - type = bool - default = true -} - -variable "oidc_thumbprints" { - description = "Thumbprints for GitHub OIDC provider" - type = list(string) - default = ["6938fd4d98bab03faadb97b34396831e3780aea1"] -} diff --git a/infra/envs/dev.tfvars b/infra/envs/dev.tfvars deleted file mode 100644 index bfe7329..0000000 --- a/infra/envs/dev.tfvars +++ /dev/null @@ -1,44 +0,0 @@ -environment = "dev" -project_name = "mypythonproject1-dev" -aws_region = "us-east-1" - -# Networking -vpc_cidr = "10.0.0.0/16" -availability_zones = ["us-east-1a", "us-east-1b"] -public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] -private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"] -database_subnet_cidrs = ["10.0.20.0/24", "10.0.21.0/24"] -app_port = 8000 - -# RDS -db_name = "gamedb" -db_username = "postgres" -db_engine_version = "17.6" -db_instance_class = "db.t3.micro" -db_allocated_storage = 20 -db_max_allocated_storage = 100 -backup_retention_days = 7 -multi_az = false -log_retention_days = 3 - -# ECS -image_tag = "dev" -frontend_image_tag = "dev" -task_cpu = "256" -task_memory = "512" -desired_count = 1 -frontend_desired_count = 1 -min_capacity = 1 -max_capacity = 3 -target_cpu_utilization = 70 -target_memory_utilization = 80 - -# ALB & Security -health_check_path = "/health" -certificate_arn = "" - -# JWT (provide during terraform apply) -jwt_secret_key = "cR3QvM7K1Rr6O4M1kXo5pQzF9vM3eS9uZ5aXK1hYbJt8g2YQ7pP9kV4cFh6D0nW8HkLm2SxAqTzB5N7uC" - -# Debug -debug = true diff --git a/infra/envs/prod.tfvars b/infra/envs/prod.tfvars deleted file mode 100644 index ab5337e..0000000 --- a/infra/envs/prod.tfvars +++ /dev/null @@ -1,44 +0,0 @@ -environment = "prod" -project_name = "mypythonproject1-prod" -aws_region = "us-east-1" - -# Networking -vpc_cidr = "10.2.0.0/16" -availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"] -public_subnet_cidrs = ["10.2.1.0/24", "10.2.2.0/24", "10.2.3.0/24"] -private_subnet_cidrs = ["10.2.10.0/24", "10.2.11.0/24", "10.2.12.0/24"] -database_subnet_cidrs = ["10.2.20.0/24", "10.2.21.0/24", "10.2.22.0/24"] -app_port = 8000 - -# RDS -db_name = "gamedb" -db_username = "postgres" -db_engine_version = "17.6" -db_instance_class = "db.t3.medium" -db_allocated_storage = 100 -db_max_allocated_storage = 500 -backup_retention_days = 30 -multi_az = true -log_retention_days = 30 - -# ECS -image_tag = "latest" -frontend_image_tag = "latest" -task_cpu = "1024" -task_memory = "2048" -desired_count = 3 -frontend_desired_count = 3 -min_capacity = 3 -max_capacity = 10 -target_cpu_utilization = 60 -target_memory_utilization = 70 - -# ALB & Security -health_check_path = "/health" -certificate_arn = "" # REQUIRED: Add your ACM certificate ARN here for production - -# JWT (provide during terraform apply) -# jwt_secret_key = "your-secret-key-here" - -# Debug -debug = false diff --git a/infra/envs/staging.tfvars b/infra/envs/staging.tfvars deleted file mode 100644 index f955deb..0000000 --- a/infra/envs/staging.tfvars +++ /dev/null @@ -1,44 +0,0 @@ -environment = "staging" -project_name = "mypythonproject1-staging" -aws_region = "us-east-1" - -# Networking -vpc_cidr = "10.1.0.0/16" -availability_zones = ["us-east-1a", "us-east-1b"] -public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24"] -private_subnet_cidrs = ["10.1.10.0/24", "10.1.11.0/24"] -database_subnet_cidrs = ["10.1.20.0/24", "10.1.21.0/24"] -app_port = 8000 - -# RDS -db_name = "gamedb" -db_username = "postgres" -db_engine_version = "17.6" -db_instance_class = "db.t3.small" -db_allocated_storage = 50 -db_max_allocated_storage = 200 -backup_retention_days = 15 -multi_az = true -log_retention_days = 7 - -# ECS -image_tag = "staging" -frontend_image_tag = "staging" -task_cpu = "512" -task_memory = "1024" -desired_count = 2 -frontend_desired_count = 2 -min_capacity = 2 -max_capacity = 5 -target_cpu_utilization = 70 -target_memory_utilization = 80 - -# ALB & Security -health_check_path = "/health" -certificate_arn = "" # Add your ACM certificate ARN here - -# JWT (provide during terraform apply) -# jwt_secret_key = "your-secret-key-here" - -# Debug -debug = false diff --git a/infra/main.tf b/infra/main.tf deleted file mode 100644 index f0aec79..0000000 --- a/infra/main.tf +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Main Terraform Configuration - * Orchestrates all modules: network, RDS, ALB, and ECS - */ - -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } - - # Partial S3 backend configuration — all values injected at `terraform init` time - # via -backend-config flags in CI workflows (staging.yml / release.yml). - # This allows the same code to target staging and production state buckets - # without storing bucket names or keys in version control. - # - # Example init command (run in CI): - # terraform init \ - # -backend-config="bucket=$TERRAFORM_STATE_BUCKET" \ - # -backend-config="key=/terraform.tfstate" \ - # -backend-config="region=$AWS_REGION" \ - # -backend-config="use_lockfile=true" - # - # Local development: run `terraform init -backend=false` to skip remote state. - backend "s3" { - encrypt = true - } -} - -locals { - frontend_ecr_repository_url = var.frontend_ecr_repository_url != "" ? var.frontend_ecr_repository_url : replace(var.ecr_repository_url, "/backend", "/frontend") - frontend_image_tag = var.frontend_image_tag != "" ? var.frontend_image_tag : var.image_tag -} - -# Networking Module -module "network" { - source = "./modules/network" - - project_name = var.project_name - vpc_cidr = var.vpc_cidr - availability_zones = var.availability_zones - public_subnet_cidrs = var.public_subnet_cidrs - private_subnet_cidrs = var.private_subnet_cidrs - database_subnet_cidrs = var.database_subnet_cidrs - app_port = var.app_port - frontend_port = var.frontend_port -} - -# RDS Module -module "rds" { - source = "./modules/rds" - - project_name = var.project_name - database_subnet_ids = module.network.database_subnet_ids - rds_security_group_id = module.network.rds_security_group_id - db_name = var.db_name - db_username = var.db_username - db_engine_version = var.db_engine_version - db_instance_class = var.db_instance_class - db_allocated_storage = var.db_allocated_storage - db_max_allocated_storage = var.db_max_allocated_storage - backup_retention_days = var.backup_retention_days - multi_az = var.multi_az - log_retention_days = var.log_retention_days - enable_secret_rotation = var.enable_secret_rotation -} - -# ALB Module -module "alb" { - source = "./modules/alb" - - project_name = var.project_name - vpc_id = module.network.vpc_id - public_subnet_ids = module.network.public_subnet_ids - alb_security_group_id = module.network.alb_security_group_id - app_port = var.app_port - frontend_port = var.frontend_port - health_check_path = var.health_check_path - certificate_arn = var.certificate_arn -} - -# JWT Secret in Secrets Manager (referenced by ECS) -resource "aws_secretsmanager_secret" "jwt_secret" { - name_prefix = "${var.project_name}-jwt-secret-" - recovery_window_in_days = 7 - kms_key_id = aws_kms_key.secrets.id - - tags = { - Name = "${var.project_name}-jwt-secret" - } -} - -# Enable automatic rotation for JWT secret -resource "aws_secretsmanager_secret_rotation" "jwt_secret" { - count = var.enable_secret_rotation ? 1 : 0 - secret_id = aws_secretsmanager_secret.jwt_secret.id - rotation_rules { - automatically_after_days = 30 - } -} - -resource "aws_secretsmanager_secret_version" "jwt_secret" { - secret_id = aws_secretsmanager_secret.jwt_secret.id - secret_string = jsonencode({ - JWT_SECRET_KEY = var.jwt_secret_key - }) -} - -# KMS Key for secrets -resource "aws_kms_key" "secrets" { - description = "KMS key for Secrets Manager" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-secrets-key" - } -} - -resource "aws_kms_alias" "secrets" { - name = "alias/${var.project_name}-secrets" - target_key_id = aws_kms_key.secrets.key_id -} - -# KMS Key Policy for Secrets Manager encryption (CKV_AWS_33) -resource "aws_kms_key_policy" "secrets" { - key_id = aws_kms_key.secrets.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "Enable IAM User Permissions" - Effect = "Allow" - Principal = { - AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" - } - Action = "kms:*" - Resource = "*" - }, - { - Sid = "Allow Secrets Manager to use the key" - Effect = "Allow" - Principal = { - Service = "secretsmanager.amazonaws.com" - } - Action = [ - "kms:Decrypt", - "kms:GenerateDataKey", - "kms:DescribeKey" - ] - Resource = "*" - Condition = { - StringEquals = { - "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" - } - } - }, - { - Sid = "Allow ECS Task Execution Role to decrypt secrets" - Effect = "Allow" - Principal = { - AWS = module.ecs.task_execution_role_arn - } - Action = [ - "kms:Decrypt", - "kms:DescribeKey" - ] - Resource = "*" - } - ] - }) -} - -# ECS Module -module "ecs" { - source = "./modules/ecs" - - project_name = var.project_name - aws_region = var.aws_region - ecr_repository_url = var.ecr_repository_url - image_tag = var.image_tag - app_port = var.app_port - task_cpu = var.task_cpu - task_memory = var.task_memory - desired_count = var.desired_count - min_capacity = var.min_capacity - max_capacity = var.max_capacity - target_cpu_utilization = var.target_cpu_utilization - target_memory_utilization = var.target_memory_utilization - log_retention_days = var.log_retention_days - private_subnet_ids = module.network.private_subnet_ids - ecs_security_group_id = module.network.ecs_tasks_security_group_id - target_group_arn = module.alb.target_group_arn - db_secret_arn = module.rds.secret_arn - jwt_secret_arn = aws_secretsmanager_secret.jwt_secret.arn - kms_key_arn = aws_kms_key.secrets.arn - db_kms_key_arn = module.rds.kms_key_arn - debug = var.debug - jwt_algorithm = var.jwt_algorithm - jwt_expire_minutes = var.jwt_expire_minutes - - depends_on = [module.alb] -} - -module "ecs_frontend" { - source = "./modules/ecs_frontend" - - project_name = var.project_name - aws_region = var.aws_region - cluster_id = module.ecs.cluster_id - ecr_repository_url = local.frontend_ecr_repository_url - image_tag = local.frontend_image_tag - frontend_port = var.frontend_port - desired_count = var.frontend_desired_count - private_subnet_ids = module.network.private_subnet_ids - ecs_security_group_id = module.network.ecs_tasks_security_group_id - target_group_arn = module.alb.frontend_target_group_arn - - depends_on = [module.alb, module.ecs] -} - -# CloudWatch Dashboard for monitoring -resource "aws_cloudwatch_dashboard" "main" { - dashboard_name = "${var.project_name}-dashboard" - - dashboard_body = jsonencode({ - widgets = [ - { - type = "metric" - properties = { - metrics = [ - ["AWS/ECS", "CPUUtilization", { stat = "Average" }], - [".", "MemoryUtilization", { stat = "Average" }], - ["AWS/RDS", "CPUUtilization", { stat = "Average" }], - [".", "DatabaseConnections", { stat = "Average" }] - ] - period = 300 - stat = "Average" - region = var.aws_region - title = "Infrastructure Metrics" - } - }, - { - type = "log" - properties = { - query = "fields @timestamp, @message | stats count() by bin(5m)" - region = var.aws_region - title = "Log Insights" - } - } - ] - }) -} diff --git a/infra/modules/alb/main.tf b/infra/modules/alb/main.tf deleted file mode 100644 index 8ac0ae6..0000000 --- a/infra/modules/alb/main.tf +++ /dev/null @@ -1,333 +0,0 @@ -/** - * ALB (Application Load Balancer) Module - * - ALB in public subnets - * - Target group for ECS tasks - * - HTTPS support with ACM certificate - * - Access logs to S3 - */ - -# S3 bucket for ALB logs -resource "aws_s3_bucket" "alb_logs" { - bucket_prefix = "${var.project_name}-alb-logs-" - - tags = { - Name = "${var.project_name}-alb-logs" - } -} - -# Enable versioning for compliance (CKV_AWS_21) -resource "aws_s3_bucket_versioning" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - versioning_configuration { - status = "Enabled" - } -} - -# Enable server-side encryption with KMS (CKV_AWS_27, CKV_AWS_145) -resource "aws_kms_key" "alb_logs" { - description = "KMS key for ALB logs bucket" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-alb-logs-key" - } -} - -resource "aws_kms_alias" "alb_logs" { - name = "alias/${var.project_name}-alb-logs" - target_key_id = aws_kms_key.alb_logs.key_id -} - -resource "aws_s3_bucket_server_side_encryption_configuration" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "aws:kms" - kms_master_key_id = aws_kms_key.alb_logs.arn - } - bucket_key_enabled = true - } -} - -# S3 Lifecycle policy (CKV2_AWS_61) -resource "aws_s3_bucket_lifecycle_configuration" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - rule { - id = "archive-old-logs" - status = "Enabled" - - filter {} - - transition { - days = 90 - storage_class = "GLACIER" - } - - expiration { - days = 365 - } - } -} - -# S3 bucket logging (CKV_AWS_18) -resource "aws_s3_bucket_logging" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - target_bucket = aws_s3_bucket.alb_logs.id - target_prefix = "access-logs/" -} - -# Block public access -resource "aws_s3_bucket_public_access_block" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true -} - -# Bucket policy for ALB to write logs with encryption enforcement -resource "aws_s3_bucket_policy" "alb_logs" { - bucket = aws_s3_bucket.alb_logs.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "DenyUnencryptedObjectUploads" - Effect = "Deny" - Principal = "*" - Action = "s3:PutObject" - Resource = "${aws_s3_bucket.alb_logs.arn}/*" - Condition = { - StringNotEquals = { - "s3:x-amz-server-side-encryption" = "aws:kms" - } - } - }, - { - Effect = "Allow" - Principal = { - AWS = data.aws_elb_service_account.main.arn - } - Action = "s3:PutObject" - Resource = "${aws_s3_bucket.alb_logs.arn}/*" - }, - { - Sid = "AllowALBLogDelivery" - Effect = "Allow" - Principal = { - Service = "logdelivery.elasticloadbalancing.amazonaws.com" - } - Action = "s3:PutObject" - Resource = "${aws_s3_bucket.alb_logs.arn}/alb-logs/*" - Condition = { - StringEquals = { - "s3:x-amz-acl" = "bucket-owner-full-control" - } - } - } - ] - }) - - depends_on = [aws_s3_bucket_public_access_block.alb_logs] -} - -# Get AWS ELB service account -data "aws_elb_service_account" "main" {} - -# Application Load Balancer -resource "aws_lb" "main" { - name = "${var.project_name}-alb" - internal = false - load_balancer_type = "application" - security_groups = [var.alb_security_group_id] - subnets = var.public_subnet_ids - - access_logs { - bucket = aws_s3_bucket.alb_logs.id - enabled = false - prefix = "alb-logs" - } - - enable_deletion_protection = true - enable_http2 = true - enable_cross_zone_load_balancing = true - - tags = { - Name = "${var.project_name}-alb" - } -} - -# Target Group (CKV_AWS_378 - use HTTPS for protocol) -resource "aws_lb_target_group" "app" { - name_prefix = "app-" - port = var.app_port - protocol = "HTTP" - vpc_id = var.vpc_id - target_type = "ip" - deregistration_delay = 30 - - stickiness { - type = "lb_cookie" - cookie_duration = 86400 - enabled = true - } - - health_check { - healthy_threshold = 2 - unhealthy_threshold = 3 - timeout = 3 - interval = 30 - path = var.health_check_path - matcher = "200" - } - - tags = { - Name = "${var.project_name}-tg" - } -} - -resource "aws_lb_target_group" "frontend" { - name_prefix = "fe-" - port = var.frontend_port - protocol = "HTTP" - vpc_id = var.vpc_id - target_type = "ip" - deregistration_delay = 30 - - health_check { - healthy_threshold = 2 - unhealthy_threshold = 3 - timeout = 5 - interval = 30 - path = "/" - matcher = "200-399" - } - - tags = { - Name = "${var.project_name}-frontend-tg" - } -} - -# HTTP Listener (redirect to HTTPS when certificate exists) -resource "aws_lb_listener" "http_redirect" { - count = var.certificate_arn != "" ? 1 : 0 - load_balancer_arn = aws_lb.main.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "redirect" - - redirect { - port = 443 - protocol = "HTTPS" - status_code = "HTTP_301" - } - } -} - -# HTTP Listener (forward to app when certificate is not configured) -resource "aws_lb_listener" "http_forward" { - count = var.certificate_arn == "" ? 1 : 0 - load_balancer_arn = aws_lb.main.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "forward" - target_group_arn = aws_lb_target_group.frontend.arn - } -} - -resource "aws_lb_listener_rule" "http_backend_routes_primary" { - count = var.certificate_arn == "" ? 1 : 0 - listener_arn = aws_lb_listener.http_forward[0].arn - priority = 100 - - action { - type = "forward" - target_group_arn = aws_lb_target_group.app.arn - } - - condition { - path_pattern { - values = slice(var.backend_path_patterns, 0, min(5, length(var.backend_path_patterns))) - } - } -} - -resource "aws_lb_listener_rule" "http_backend_routes_secondary" { - count = var.certificate_arn == "" && length(var.backend_path_patterns) > 5 ? 1 : 0 - listener_arn = aws_lb_listener.http_forward[0].arn - priority = 110 - - action { - type = "forward" - target_group_arn = aws_lb_target_group.app.arn - } - - condition { - path_pattern { - values = slice(var.backend_path_patterns, 5, length(var.backend_path_patterns)) - } - } -} - -# HTTPS Listener (requires certificate_arn) -resource "aws_lb_listener" "https" { - count = var.certificate_arn != "" ? 1 : 0 - load_balancer_arn = aws_lb.main.arn - port = 443 - protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" - certificate_arn = var.certificate_arn - - default_action { - type = "forward" - target_group_arn = aws_lb_target_group.frontend.arn - } -} - -resource "aws_lb_listener_rule" "https_backend_routes_primary" { - count = var.certificate_arn != "" ? 1 : 0 - listener_arn = aws_lb_listener.https[0].arn - priority = 100 - - action { - type = "forward" - target_group_arn = aws_lb_target_group.app.arn - } - - condition { - path_pattern { - values = slice(var.backend_path_patterns, 0, min(5, length(var.backend_path_patterns))) - } - } -} - -resource "aws_lb_listener_rule" "https_backend_routes_secondary" { - count = var.certificate_arn != "" && length(var.backend_path_patterns) > 5 ? 1 : 0 - listener_arn = aws_lb_listener.https[0].arn - priority = 110 - - action { - type = "forward" - target_group_arn = aws_lb_target_group.app.arn - } - - condition { - path_pattern { - values = slice(var.backend_path_patterns, 5, length(var.backend_path_patterns)) - } - } -} - -# Note: HTTP_forward listener removed. ALB requires certificate_arn. -# All HTTP traffic must redirect to HTTPS via http listener. diff --git a/infra/modules/alb/outputs.tf b/infra/modules/alb/outputs.tf deleted file mode 100644 index 3ea0922..0000000 --- a/infra/modules/alb/outputs.tf +++ /dev/null @@ -1,24 +0,0 @@ -output "alb_dns_name" { - description = "ALB DNS name" - value = aws_lb.main.dns_name -} - -output "alb_zone_id" { - description = "ALB zone ID" - value = aws_lb.main.zone_id -} - -output "target_group_arn" { - description = "Target group ARN" - value = aws_lb_target_group.app.arn -} - -output "target_group_name" { - description = "Target group name" - value = aws_lb_target_group.app.name -} - -output "frontend_target_group_arn" { - description = "Frontend target group ARN" - value = aws_lb_target_group.frontend.arn -} diff --git a/infra/modules/alb/variables.tf b/infra/modules/alb/variables.tf deleted file mode 100644 index d750352..0000000 --- a/infra/modules/alb/variables.tf +++ /dev/null @@ -1,49 +0,0 @@ -variable "project_name" { - description = "Project name" - type = string -} - -variable "vpc_id" { - description = "VPC ID" - type = string -} - -variable "public_subnet_ids" { - description = "Public subnet IDs" - type = list(string) -} - -variable "alb_security_group_id" { - description = "ALB security group ID" - type = string -} - -variable "app_port" { - description = "Application port" - type = number - default = 8000 -} - -variable "frontend_port" { - description = "Frontend container port" - type = number - default = 4200 -} - -variable "backend_path_patterns" { - description = "Path patterns that should route to backend target group" - type = list(string) - default = ["/health", "/health/*", "/docs", "/docs/*", "/redoc", "/redoc/*", "/openapi.json", "/api/*"] -} - -variable "health_check_path" { - description = "Health check path" - type = string - default = "/health" -} - -variable "certificate_arn" { - description = "ACM certificate ARN for HTTPS" - type = string - default = "" -} diff --git a/infra/modules/ecs/main.tf b/infra/modules/ecs/main.tf deleted file mode 100644 index d1b80e7..0000000 --- a/infra/modules/ecs/main.tf +++ /dev/null @@ -1,329 +0,0 @@ -/** - * ECS Fargate Module - * - ECS Cluster - * - Task definition with CloudWatch logging - * - ECS Service with Auto Scaling - * - IAM roles with least privilege - */ - -# KMS Key for ECS CloudWatch Logs -resource "aws_kms_key" "ecs_logs" { - description = "KMS key for ECS CloudWatch Logs" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-ecs-logs-key" - } -} - -# CloudWatch Log Group for ECS with KMS encryption and 1-year retention (CKV_AWS_158, CKV_AWS_338) -resource "aws_cloudwatch_log_group" "ecs" { - name = "/ecs/${var.project_name}" - retention_in_days = var.log_retention_days - - tags = { - Name = "${var.project_name}-ecs-logs" - } -} - -# ECS Cluster -resource "aws_ecs_cluster" "main" { - name = "${var.project_name}-cluster" - - setting { - name = "containerInsights" - value = "enabled" - } - - tags = { - Name = "${var.project_name}-cluster" - } -} - -# ECS Cluster Capacity Providers -resource "aws_ecs_cluster_capacity_providers" "main" { - cluster_name = aws_ecs_cluster.main.name - - capacity_providers = ["FARGATE", "FARGATE_SPOT"] - - default_capacity_provider_strategy { - base = 1 - weight = 100 - capacity_provider = "FARGATE" - } -} - -# IAM Role for ECS Task Execution -resource "aws_iam_role" "ecs_task_execution" { - name = "${var.project_name}-ecs-task-execution-role" - - 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" "ecs_task_execution" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -# IAM Policy for ECS Task to access Secrets Manager -resource "aws_iam_role_policy" "ecs_task_execution_secrets" { - name = "${var.project_name}-ecs-task-execution-secrets" - role = aws_iam_role.ecs_task_execution.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue" - ] - Resource = [ - var.db_secret_arn, - var.jwt_secret_arn - ] - }, - { - Effect = "Allow" - Action = [ - "kms:Decrypt", - "kms:DescribeKey" - ] - Resource = [ - var.kms_key_arn, - var.db_kms_key_arn - ] - } - ] - }) -} - -# IAM Role for ECS Task (application permissions) -resource "aws_iam_role" "ecs_task" { - name = "${var.project_name}-ecs-task-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - } - ] - }) -} - -# ECS Task Definition -resource "aws_ecs_task_definition" "app" { - family = "${var.project_name}-task" - network_mode = "awsvpc" - requires_compatibilities = ["FARGATE"] - cpu = var.task_cpu - memory = var.task_memory - execution_role_arn = aws_iam_role.ecs_task_execution.arn - task_role_arn = aws_iam_role.ecs_task.arn - - container_definitions = jsonencode([ - { - name = var.project_name - image = "${var.ecr_repository_url}:${var.image_tag}" - essential = true - portMappings = [ - { - containerPort = var.app_port - hostPort = var.app_port - protocol = "tcp" - } - ] - - environment = [ - { - name = "DEBUG" - value = var.debug ? "true" : "false" - }, - { - name = "JWT_ALGORITHM" - value = var.jwt_algorithm - }, - { - name = "JWT_EXPIRE_MINUTES" - value = tostring(var.jwt_expire_minutes) - } - ] - - secrets = [ - { - name = "DATABASE_URL" - valueFrom = "${var.db_secret_arn}:DATABASE_URL::" - }, - { - name = "DATABASE_USER" - valueFrom = "${var.db_secret_arn}:DATABASE_USER::" - }, - { - name = "DATABASE_PASSWORD" - valueFrom = "${var.db_secret_arn}:DATABASE_PASSWORD::" - }, - { - name = "DATABASE_HOST" - valueFrom = "${var.db_secret_arn}:DATABASE_HOST::" - }, - { - name = "DATABASE_PORT" - valueFrom = "${var.db_secret_arn}:DATABASE_PORT::" - }, - { - name = "DATABASE_NAME" - valueFrom = "${var.db_secret_arn}:DATABASE_NAME::" - }, - { - name = "JWT_SECRET_KEY" - valueFrom = "${var.jwt_secret_arn}:JWT_SECRET_KEY::" - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.ecs.name - "awslogs-region" = var.aws_region - "awslogs-stream-prefix" = "ecs" - } - } - - healthCheck = { - command = ["CMD-SHELL", "curl -f http://localhost:${var.app_port}/health || exit 1"] - interval = 30 - timeout = 5 - retries = 3 - startPeriod = 60 - } - } - ]) - - tags = { - Name = "${var.project_name}-task" - } -} - -# ECS Service -resource "aws_ecs_service" "app" { - name = "${var.project_name}-service" - cluster = aws_ecs_cluster.main.id - task_definition = aws_ecs_task_definition.app.arn - desired_count = var.desired_count - launch_type = "FARGATE" - - network_configuration { - subnets = var.private_subnet_ids - security_groups = [var.ecs_security_group_id] - assign_public_ip = false - } - - load_balancer { - target_group_arn = var.target_group_arn - container_name = var.project_name - container_port = var.app_port - } - - depends_on = [ - aws_ecs_task_definition.app - ] - - tags = { - Name = "${var.project_name}-service" - } - - lifecycle { - ignore_changes = [desired_count] - } -} - -# Auto Scaling Target -resource "aws_appautoscaling_target" "ecs_target" { - max_capacity = var.max_capacity - min_capacity = var.min_capacity - resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}" - scalable_dimension = "ecs:service:DesiredCount" - service_namespace = "ecs" -} - -# Auto Scaling Policy - CPU -resource "aws_appautoscaling_policy" "ecs_cpu" { - name = "${var.project_name}-cpu-scaling" - policy_type = "TargetTrackingScaling" - resource_id = aws_appautoscaling_target.ecs_target.resource_id - scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension - service_namespace = aws_appautoscaling_target.ecs_target.service_namespace - - target_tracking_scaling_policy_configuration { - predefined_metric_specification { - predefined_metric_type = "ECSServiceAverageCPUUtilization" - } - target_value = var.target_cpu_utilization - } -} - -# Auto Scaling Policy - Memory -resource "aws_appautoscaling_policy" "ecs_memory" { - name = "${var.project_name}-memory-scaling" - policy_type = "TargetTrackingScaling" - resource_id = aws_appautoscaling_target.ecs_target.resource_id - scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension - service_namespace = aws_appautoscaling_target.ecs_target.service_namespace - - target_tracking_scaling_policy_configuration { - predefined_metric_specification { - predefined_metric_type = "ECSServiceAverageMemoryUtilization" - } - target_value = var.target_memory_utilization - } -} - -# CloudWatch Alarms for monitoring -resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" { - alarm_name = "${var.project_name}-ecs-cpu-high" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 2 - metric_name = "CPUUtilization" - namespace = "AWS/ECS" - period = 300 - statistic = "Average" - threshold = 80 - - dimensions = { - ClusterName = aws_ecs_cluster.main.name - ServiceName = aws_ecs_service.app.name - } -} - -resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" { - alarm_name = "${var.project_name}-ecs-memory-high" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 2 - metric_name = "MemoryUtilization" - namespace = "AWS/ECS" - period = 300 - statistic = "Average" - threshold = 80 - - dimensions = { - ClusterName = aws_ecs_cluster.main.name - ServiceName = aws_ecs_service.app.name - } -} diff --git a/infra/modules/ecs/outputs.tf b/infra/modules/ecs/outputs.tf deleted file mode 100644 index c30d51b..0000000 --- a/infra/modules/ecs/outputs.tf +++ /dev/null @@ -1,34 +0,0 @@ -output "cluster_name" { - description = "ECS cluster name" - value = aws_ecs_cluster.main.name -} - -output "cluster_id" { - description = "ECS cluster ID" - value = aws_ecs_cluster.main.id -} - -output "service_name" { - description = "ECS service name" - value = aws_ecs_service.app.name -} - -output "service_id" { - description = "ECS service ID" - value = aws_ecs_service.app.id -} - -output "task_definition_arn" { - description = "ECS task definition ARN" - value = aws_ecs_task_definition.app.arn -} - -output "log_group_name" { - description = "CloudWatch log group name" - value = aws_cloudwatch_log_group.ecs.name -} - -output "task_execution_role_arn" { - description = "ECS task execution role ARN (used for KMS key policy)" - value = aws_iam_role.ecs_task_execution.arn -} diff --git a/infra/modules/ecs/variables.tf b/infra/modules/ecs/variables.tf deleted file mode 100644 index 91a422d..0000000 --- a/infra/modules/ecs/variables.tf +++ /dev/null @@ -1,128 +0,0 @@ -variable "project_name" { - description = "Project name" - type = string -} - -variable "aws_region" { - description = "AWS region" - type = string - default = "us-east-1" -} - -variable "ecr_repository_url" { - description = "ECR repository URL" - type = string -} - -variable "image_tag" { - description = "Docker image tag" - type = string - default = "latest" -} - -variable "app_port" { - description = "Application port" - type = number - default = 8000 -} - -variable "task_cpu" { - description = "ECS task CPU (256, 512, 1024, 2048, 4096)" - type = string - default = "512" -} - -variable "task_memory" { - description = "ECS task memory (512, 1024, 2048, 3072, 4096, etc.)" - type = string - default = "1024" -} - -variable "desired_count" { - description = "Desired number of tasks" - type = number - default = 2 -} - -variable "min_capacity" { - description = "Minimum number of tasks" - type = number - default = 2 -} - -variable "max_capacity" { - description = "Maximum number of tasks" - type = number - default = 10 -} - -variable "target_cpu_utilization" { - description = "Target CPU utilization for auto-scaling" - type = number - default = 70 -} - -variable "target_memory_utilization" { - description = "Target memory utilization for auto-scaling" - type = number - default = 80 -} - -variable "log_retention_days" { - description = "CloudWatch log retention in days" - type = number - default = 7 -} - -variable "private_subnet_ids" { - description = "Private subnet IDs for ECS tasks" - type = list(string) -} - -variable "ecs_security_group_id" { - description = "ECS tasks security group ID" - type = string -} - -variable "target_group_arn" { - description = "ALB target group ARN" - type = string -} - -variable "db_secret_arn" { - description = "RDS database secret ARN" - type = string -} - -variable "jwt_secret_arn" { - description = "JWT secret ARN" - type = string -} - -variable "kms_key_arn" { - description = "KMS key ARN for secret decryption" - type = string -} - -variable "db_kms_key_arn" { - description = "KMS key ARN used to encrypt DB secret" - type = string -} - -variable "debug" { - description = "Enable debug mode" - type = bool - default = false -} - -variable "jwt_algorithm" { - description = "JWT algorithm" - type = string - default = "HS256" -} - -variable "jwt_expire_minutes" { - description = "JWT expiration time in minutes" - type = number - default = 60 -} diff --git a/infra/modules/ecs_frontend/main.tf b/infra/modules/ecs_frontend/main.tf deleted file mode 100644 index c76fa3f..0000000 --- a/infra/modules/ecs_frontend/main.tf +++ /dev/null @@ -1,92 +0,0 @@ -resource "aws_cloudwatch_log_group" "frontend" { - name = "/ecs/${var.project_name}-frontend" - retention_in_days = 30 - - tags = { - Name = "${var.project_name}-frontend-logs" - } -} - -resource "aws_iam_role" "ecs_task_execution" { - name = "${var.project_name}-frontend-exec-role" - - 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" "ecs_task_execution" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -resource "aws_ecs_task_definition" "frontend" { - family = "${var.project_name}-frontend-task" - network_mode = "awsvpc" - requires_compatibilities = ["FARGATE"] - cpu = "256" - memory = "512" - execution_role_arn = aws_iam_role.ecs_task_execution.arn - - container_definitions = jsonencode([ - { - name = "${var.project_name}-frontend" - image = "${var.ecr_repository_url}:${var.image_tag}" - essential = true - portMappings = [ - { - containerPort = var.frontend_port - hostPort = var.frontend_port - protocol = "tcp" - } - ] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.frontend.name - "awslogs-region" = var.aws_region - "awslogs-stream-prefix" = "ecs" - } - } - } - ]) - - tags = { - Name = "${var.project_name}-frontend-task" - } -} - -resource "aws_ecs_service" "frontend" { - name = "${var.project_name}-frontend-service" - cluster = var.cluster_id - task_definition = aws_ecs_task_definition.frontend.arn - desired_count = var.desired_count - launch_type = "FARGATE" - - network_configuration { - subnets = var.private_subnet_ids - security_groups = [var.ecs_security_group_id] - assign_public_ip = false - } - - load_balancer { - target_group_arn = var.target_group_arn - container_name = "${var.project_name}-frontend" - container_port = var.frontend_port - } - - depends_on = [aws_ecs_task_definition.frontend] - - tags = { - Name = "${var.project_name}-frontend-service" - } -} diff --git a/infra/modules/ecs_frontend/outputs.tf b/infra/modules/ecs_frontend/outputs.tf deleted file mode 100644 index 17d806b..0000000 --- a/infra/modules/ecs_frontend/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "service_name" { - description = "Frontend ECS service name" - value = aws_ecs_service.frontend.name -} - -output "task_definition_arn" { - description = "Frontend task definition ARN" - value = aws_ecs_task_definition.frontend.arn -} diff --git a/infra/modules/ecs_frontend/variables.tf b/infra/modules/ecs_frontend/variables.tf deleted file mode 100644 index 055004c..0000000 --- a/infra/modules/ecs_frontend/variables.tf +++ /dev/null @@ -1,52 +0,0 @@ -variable "project_name" { - description = "Project name" - type = string -} - -variable "aws_region" { - description = "AWS region" - type = string - default = "us-east-1" -} - -variable "cluster_id" { - description = "ECS cluster ID" - type = string -} - -variable "ecr_repository_url" { - description = "Frontend ECR repository URL" - type = string -} - -variable "image_tag" { - description = "Frontend Docker image tag" - type = string -} - -variable "frontend_port" { - description = "Frontend container port" - type = number - default = 4200 -} - -variable "desired_count" { - description = "Desired number of frontend tasks" - type = number - default = 1 -} - -variable "private_subnet_ids" { - description = "Private subnet IDs for ECS tasks" - type = list(string) -} - -variable "ecs_security_group_id" { - description = "ECS tasks security group ID" - type = string -} - -variable "target_group_arn" { - description = "ALB target group ARN for frontend service" - type = string -} diff --git a/infra/modules/network/main.tf b/infra/modules/network/main.tf deleted file mode 100644 index 2e356e2..0000000 --- a/infra/modules/network/main.tf +++ /dev/null @@ -1,359 +0,0 @@ -/** - * VPC and Networking Resources - * - VPC with configurable CIDR - * - Public and private subnets across multiple AZs - * - NAT Gateway for private subnet internet access - * - Internet Gateway and route tables - */ - -# Data source for current AWS account ID -data "aws_caller_identity" "current" {} - -# VPC -resource "aws_vpc" "main" { - cidr_block = var.vpc_cidr - enable_dns_hostnames = true - enable_dns_support = true - - tags = { - Name = "${var.project_name}-vpc" - } -} - -# Restrict default security group (CKV2_AWS_12) -resource "aws_default_security_group" "default" { - vpc_id = aws_vpc.main.id - - tags = { - Name = "${var.project_name}-default-sg" - } -} - -# VPC Flow Logs (CKV2_AWS_11) -resource "aws_flow_log" "main" { - iam_role_arn = aws_iam_role.flow_logs.arn - log_destination = aws_cloudwatch_log_group.flow_logs.arn - traffic_type = "ALL" - vpc_id = aws_vpc.main.id - - tags = { - Name = "${var.project_name}-vpc-flow-logs" - } -} - -resource "aws_cloudwatch_log_group" "flow_logs" { - name = "/aws/vpc/flowlogs/${var.project_name}" - retention_in_days = 30 - - tags = { - Name = "${var.project_name}-vpc-flow-logs" - } -} - -resource "aws_kms_key" "flow_logs" { - description = "KMS key for VPC Flow Logs" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-flow-logs-key" - } -} - -resource "aws_iam_role" "flow_logs" { - name = "${var.project_name}-vpc-flow-logs-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "vpc-flow-logs.amazonaws.com" - } - } - ] - }) -} - -resource "aws_iam_role_policy" "flow_logs" { - name = "${var.project_name}-vpc-flow-logs-policy" - role = aws_iam_role.flow_logs.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents", - "logs:DescribeLogGroups", - "logs:DescribeLogStreams" - ] - Effect = "Allow" - Resource = "*" - } - ] - }) -} - -# Internet Gateway -resource "aws_internet_gateway" "main" { - vpc_id = aws_vpc.main.id - - tags = { - Name = "${var.project_name}-igw" - } -} - -# Elastic IP for NAT Gateway -resource "aws_eip" "nat" { - domain = "vpc" - depends_on = [aws_internet_gateway.main] - - tags = { - Name = "${var.project_name}-nat-eip" - } -} - -# NAT Gateway (in public subnet for high availability) -resource "aws_nat_gateway" "main" { - allocation_id = aws_eip.nat.id - subnet_id = aws_subnet.public[0].id - - depends_on = [aws_internet_gateway.main] - - tags = { - Name = "${var.project_name}-nat" - } -} - -# Public Subnets -resource "aws_subnet" "public" { - count = length(var.availability_zones) - vpc_id = aws_vpc.main.id - cidr_block = var.public_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - map_public_ip_on_launch = false - - tags = { - Name = "${var.project_name}-public-subnet-${count.index + 1}" - Type = "Public" - } -} - -# Private Subnets -resource "aws_subnet" "private" { - count = length(var.availability_zones) - vpc_id = aws_vpc.main.id - cidr_block = var.private_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - - tags = { - Name = "${var.project_name}-private-subnet-${count.index + 1}" - Type = "Private" - } -} - -# Database Subnets (for RDS) -resource "aws_subnet" "database" { - count = length(var.availability_zones) - vpc_id = aws_vpc.main.id - cidr_block = var.database_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - - tags = { - Name = "${var.project_name}-database-subnet-${count.index + 1}" - Type = "Database" - } -} - -# Route Table for Public Subnets -resource "aws_route_table" "public" { - vpc_id = aws_vpc.main.id - - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.main.id - } - - tags = { - Name = "${var.project_name}-public-rt" - } -} - -# Route Table for Private Subnets -resource "aws_route_table" "private" { - vpc_id = aws_vpc.main.id - - route { - cidr_block = "0.0.0.0/0" - nat_gateway_id = aws_nat_gateway.main.id - } - - tags = { - Name = "${var.project_name}-private-rt" - } -} - -# Route Table for Database Subnets (no internet access) -resource "aws_route_table" "database" { - vpc_id = aws_vpc.main.id - - tags = { - Name = "${var.project_name}-database-rt" - } -} - -# Public Route Table Associations -resource "aws_route_table_association" "public" { - count = length(aws_subnet.public) - subnet_id = aws_subnet.public[count.index].id - route_table_id = aws_route_table.public.id -} - -# Private Route Table Associations -resource "aws_route_table_association" "private" { - count = length(aws_subnet.private) - subnet_id = aws_subnet.private[count.index].id - route_table_id = aws_route_table.private.id -} - -# Database Route Table Associations -resource "aws_route_table_association" "database" { - count = length(aws_subnet.database) - subnet_id = aws_subnet.database[count.index].id - route_table_id = aws_route_table.database.id -} - -# Security Group for ALB -resource "aws_security_group" "alb" { - name_prefix = "${var.project_name}-alb-" - description = "Security group for ALB - allows HTTP/HTTPS from internet" - vpc_id = aws_vpc.main.id - - ingress { - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow HTTP from Internet" - } - - ingress { - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow HTTPS from Internet" - } - - egress { - from_port = 0 - to_port = 65535 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow outbound TCP traffic" - } - - egress { - from_port = 0 - to_port = 65535 - protocol = "udp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow outbound UDP traffic" - } - - tags = { - Name = "${var.project_name}-alb-sg" - } -} - -# Security Group for ECS Tasks -resource "aws_security_group" "ecs_tasks" { - name_prefix = "${var.project_name}-ecs-tasks-" - description = "Security group for ECS tasks - allows traffic from ALB" - vpc_id = aws_vpc.main.id - - ingress { - from_port = var.app_port - to_port = var.app_port - protocol = "tcp" - security_groups = [aws_security_group.alb.id] - description = "Allow app port from ALB" - } - - ingress { - from_port = var.frontend_port - to_port = var.frontend_port - protocol = "tcp" - security_groups = [aws_security_group.alb.id] - description = "Allow frontend port from ALB" - } - - egress { - from_port = 0 - to_port = 65535 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow outbound TCP traffic" - } - - egress { - from_port = 0 - to_port = 65535 - protocol = "udp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow outbound UDP traffic" - } - - tags = { - Name = "${var.project_name}-ecs-tasks-sg" - } -} - -# Security Group for RDS -resource "aws_security_group" "rds" { - name_prefix = "${var.project_name}-rds-" - description = "Security group for RDS" - vpc_id = aws_vpc.main.id - - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - security_groups = [aws_security_group.ecs_tasks.id] - description = "PostgreSQL from ECS" - } - - # Restrict egress to only necessary services (CKV_AWS_62) - egress { - from_port = 53 - to_port = 53 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "DNS TCP" - } - - egress { - from_port = 53 - to_port = 53 - protocol = "udp" - cidr_blocks = ["0.0.0.0/0"] - description = "DNS UDP" - } - - egress { - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "HTTPS for AWS APIs" - } - - tags = { - Name = "${var.project_name}-rds-sg" - } -} diff --git a/infra/modules/network/outputs.tf b/infra/modules/network/outputs.tf deleted file mode 100644 index 68b9f4a..0000000 --- a/infra/modules/network/outputs.tf +++ /dev/null @@ -1,39 +0,0 @@ -output "vpc_id" { - description = "VPC ID" - value = aws_vpc.main.id -} - -output "public_subnet_ids" { - description = "Public subnet IDs" - value = aws_subnet.public[*].id -} - -output "private_subnet_ids" { - description = "Private subnet IDs" - value = aws_subnet.private[*].id -} - -output "database_subnet_ids" { - description = "Database subnet IDs" - value = aws_subnet.database[*].id -} - -output "alb_security_group_id" { - description = "ALB security group ID" - value = aws_security_group.alb.id -} - -output "ecs_tasks_security_group_id" { - description = "ECS tasks security group ID" - value = aws_security_group.ecs_tasks.id -} - -output "rds_security_group_id" { - description = "RDS security group ID" - value = aws_security_group.rds.id -} - -output "nat_gateway_ip" { - description = "NAT Gateway Elastic IP" - value = aws_eip.nat.public_ip -} diff --git a/infra/modules/network/variables.tf b/infra/modules/network/variables.tf deleted file mode 100644 index dc4c212..0000000 --- a/infra/modules/network/variables.tf +++ /dev/null @@ -1,46 +0,0 @@ -variable "project_name" { - description = "Project name for resource naming" - type = string -} - -variable "vpc_cidr" { - description = "CIDR block for VPC" - type = string - default = "10.0.0.0/16" -} - -variable "availability_zones" { - description = "List of availability zones" - type = list(string) - default = ["us-east-1a", "us-east-1b"] -} - -variable "public_subnet_cidrs" { - description = "CIDR blocks for public subnets" - type = list(string) - default = ["10.0.1.0/24", "10.0.2.0/24"] -} - -variable "private_subnet_cidrs" { - description = "CIDR blocks for private subnets" - type = list(string) - default = ["10.0.10.0/24", "10.0.11.0/24"] -} - -variable "database_subnet_cidrs" { - description = "CIDR blocks for database subnets" - type = list(string) - default = ["10.0.20.0/24", "10.0.21.0/24"] -} - -variable "app_port" { - description = "Port for application (FastAPI)" - type = number - default = 8000 -} - -variable "frontend_port" { - description = "Port for frontend service" - type = number - default = 4200 -} diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf deleted file mode 100644 index 94f5165..0000000 --- a/infra/modules/rds/main.tf +++ /dev/null @@ -1,208 +0,0 @@ -/** - * RDS PostgreSQL Module - * - PostgreSQL instance in private subnets - * - Credentials stored in AWS Secrets Manager - * - Automated backups and encryption - */ - -# Data source for current AWS account ID -data "aws_caller_identity" "current" {} - -# Generate random password -resource "random_password" "db_password" { - length = 32 - special = true - override_special = "!#$%&*()-_=+[]{}<>:?" -} - -# Store password in Secrets Manager with KMS encryption (CKV_AWS_149) -resource "aws_secretsmanager_secret" "db_password" { - name_prefix = "${var.project_name}-db-password-" - recovery_window_in_days = 7 - kms_key_id = aws_kms_key.rds.id - - tags = { - Name = "${var.project_name}-db-password" - } -} - -# Enable automatic rotation for DB password (CKV2_AWS_57) -resource "aws_secretsmanager_secret_rotation" "db_password" { - count = var.enable_secret_rotation ? 1 : 0 - secret_id = aws_secretsmanager_secret.db_password.id - rotation_rules { - automatically_after_days = 30 - } -} - -resource "aws_secretsmanager_secret_version" "db_password" { - secret_id = aws_secretsmanager_secret.db_password.id - secret_string = jsonencode({ - username = var.db_username - password = random_password.db_password.result - engine = "postgres" - host = aws_db_instance.main.address - port = aws_db_instance.main.port - dbname = var.db_name - DATABASE_USER = var.db_username - DATABASE_PASSWORD = random_password.db_password.result - DATABASE_HOST = aws_db_instance.main.address - DATABASE_PORT = tostring(aws_db_instance.main.port) - DATABASE_NAME = var.db_name - DATABASE_URL = "postgresql://${var.db_username}:${random_password.db_password.result}@${aws_db_instance.main.address}:${aws_db_instance.main.port}/${var.db_name}" - }) - # Ensure the secret value update happens after the RDS instance is created - depends_on = [aws_db_instance.main] -} - -# DB Subnet Group -resource "aws_db_subnet_group" "main" { - name = "${var.project_name}-db-subnet-group" - subnet_ids = var.database_subnet_ids - - tags = { - Name = "${var.project_name}-db-subnet-group" - } -} - -# RDS Instance -resource "aws_db_instance" "main" { - identifier = "${var.project_name}-db" - engine = "postgres" - engine_version = var.db_engine_version - instance_class = var.db_instance_class - allocated_storage = var.db_allocated_storage - max_allocated_storage = var.db_max_allocated_storage - db_name = var.db_name - username = var.db_username - password = random_password.db_password.result - db_subnet_group_name = aws_db_subnet_group.main.name - vpc_security_group_ids = [var.rds_security_group_id] - - # Security - skip_final_snapshot = false - final_snapshot_identifier = "${var.project_name}-db-final-snapshot-${formatdate("YYYY-MM-DD-hhmm", timestamp())}" - copy_tags_to_snapshot = true - publicly_accessible = false - storage_encrypted = true - kms_key_id = aws_kms_key.rds.arn - iam_database_authentication_enabled = true - deletion_protection = true - - # Backups - backup_retention_period = var.backup_retention_days - backup_window = "03:00-04:00" - maintenance_window = "mon:04:00-mon:05:00" - multi_az = var.multi_az - auto_minor_version_upgrade = true - - # Monitoring - enabled_cloudwatch_logs_exports = ["postgresql"] - monitoring_interval = 60 - monitoring_role_arn = aws_iam_role.rds_monitoring.arn - - # Performance Insights with KMS encryption (CKV_AWS_354) - performance_insights_enabled = true - performance_insights_kms_key_id = aws_kms_key.rds.arn - performance_insights_retention_period = 7 - - tags = { - Name = "${var.project_name}-db" - } - - depends_on = [ - ] -} - -# KMS Key for RDS encryption -resource "aws_kms_key" "rds" { - description = "KMS key for RDS encryption" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-rds-key" - } -} - -# KMS Key Policy for RDS (CKV2_AWS_64) -resource "aws_kms_key_policy" "rds" { - key_id = aws_kms_key.rds.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "Enable IAM User Permissions" - Effect = "Allow" - Principal = { - AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" - } - Action = "kms:*" - Resource = "*" - }, - { - Sid = "Allow RDS to use the key" - Effect = "Allow" - Principal = { - Service = "rds.amazonaws.com" - } - Action = [ - "kms:Decrypt", - "kms:GenerateDataKey", - "kms:DescribeKey" - ] - Resource = "*" - } - ] - }) -} - -resource "aws_kms_alias" "rds" { - name = "alias/${var.project_name}-rds" - target_key_id = aws_kms_key.rds.key_id -} - -# IAM Role for RDS monitoring -resource "aws_iam_role" "rds_monitoring" { - name = "${var.project_name}-rds-monitoring-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "monitoring.rds.amazonaws.com" - } - } - ] - }) -} - -resource "aws_iam_role_policy_attachment" "rds_monitoring" { - role = aws_iam_role.rds_monitoring.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" -} - -# KMS Key for CloudWatch Logs -resource "aws_kms_key" "cloudwatch_logs" { - description = "KMS key for CloudWatch Logs" - deletion_window_in_days = 10 - enable_key_rotation = true - - tags = { - Name = "${var.project_name}-cloudwatch-logs-key" - } -} - -# CloudWatch Log Group for RDS with KMS encryption and 1-year retention (CKV_AWS_158, CKV_AWS_338) -resource "aws_cloudwatch_log_group" "rds" { - name = "/aws/rds/${var.project_name}" - retention_in_days = var.log_retention_days - - tags = { - Name = "${var.project_name}-rds-logs" - } -} diff --git a/infra/modules/rds/outputs.tf b/infra/modules/rds/outputs.tf deleted file mode 100644 index 3786a48..0000000 --- a/infra/modules/rds/outputs.tf +++ /dev/null @@ -1,39 +0,0 @@ -output "db_endpoint" { - description = "RDS endpoint" - value = aws_db_instance.main.endpoint -} - -output "db_address" { - description = "RDS address" - value = aws_db_instance.main.address -} - -output "db_port" { - description = "RDS port" - value = aws_db_instance.main.port -} - -output "db_name" { - description = "Database name" - value = aws_db_instance.main.db_name -} - -output "db_username" { - description = "Database username" - value = aws_db_instance.main.username -} - -output "secret_arn" { - description = "Secrets Manager secret ARN" - value = aws_secretsmanager_secret.db_password.arn -} - -output "secret_name" { - description = "Secrets Manager secret name" - value = aws_secretsmanager_secret.db_password.name -} - -output "kms_key_arn" { - description = "RDS KMS key ARN" - value = aws_kms_key.rds.arn -} diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf deleted file mode 100644 index 1a04ded..0000000 --- a/infra/modules/rds/variables.tf +++ /dev/null @@ -1,74 +0,0 @@ -variable "project_name" { - description = "Project name" - type = string -} - -variable "database_subnet_ids" { - description = "Database subnet IDs" - type = list(string) -} - -variable "rds_security_group_id" { - description = "RDS security group ID" - type = string -} - -variable "db_name" { - description = "Database name" - type = string - default = "gamedb" -} - -variable "db_username" { - description = "Database master username" - type = string - default = "postgres" -} - -variable "db_engine_version" { - description = "PostgreSQL engine version" - type = string - default = "16.1" -} - -variable "db_instance_class" { - description = "Database instance class" - type = string - default = "db.t3.micro" -} - -variable "db_allocated_storage" { - description = "Allocated storage in GB" - type = number - default = 20 -} - -variable "db_max_allocated_storage" { - description = "Maximum allocated storage in GB for auto-scaling" - type = number - default = 100 -} - -variable "backup_retention_days" { - description = "Backup retention period in days" - type = number - default = 30 -} - -variable "multi_az" { - description = "Enable Multi-AZ deployment" - type = bool - default = true -} - -variable "log_retention_days" { - description = "CloudWatch log retention in days" - type = number - default = 7 -} - -variable "enable_secret_rotation" { - description = "Enable Secrets Manager secret rotation resources (requires Lambda rotation function integration)" - type = bool - default = false -} diff --git a/infra/outputs.tf b/infra/outputs.tf deleted file mode 100644 index d337a2b..0000000 --- a/infra/outputs.tf +++ /dev/null @@ -1,34 +0,0 @@ -output "alb_dns_name" { - description = "ALB DNS name" - value = module.alb.alb_dns_name -} - -output "ecs_cluster_name" { - description = "ECS cluster name" - value = module.ecs.cluster_name -} - -output "ecs_service_name" { - description = "ECS service name" - value = module.ecs.service_name -} - -output "frontend_ecs_service_name" { - description = "Frontend ECS service name" - value = module.ecs_frontend.service_name -} - -output "rds_endpoint" { - description = "RDS endpoint" - value = module.rds.db_endpoint -} - -output "vpc_id" { - description = "VPC ID" - value = module.network.vpc_id -} - -output "cloudwatch_dashboard_url" { - description = "CloudWatch dashboard URL" - value = "https://console.aws.amazon.com/cloudwatch/home?region=${var.aws_region}#dashboards:name=${var.project_name}-dashboard" -} diff --git a/infra/providers.tf b/infra/providers.tf deleted file mode 100644 index a9f861f..0000000 --- a/infra/providers.tf +++ /dev/null @@ -1,15 +0,0 @@ -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Project = var.project_name - Environment = var.environment - ManagedBy = "Terraform" - } - } -} - -# Data source for current AWS account ID (used in KMS key policies) -data "aws_caller_identity" "current" { -} diff --git a/infra/variables.tf b/infra/variables.tf deleted file mode 100644 index 5d5f2e2..0000000 --- a/infra/variables.tf +++ /dev/null @@ -1,236 +0,0 @@ -variable "aws_region" { - description = "AWS region" - type = string - default = "us-east-1" -} - -variable "project_name" { - description = "Project name" - type = string - default = "mypythonproject1" -} - -variable "environment" { - description = "Environment (dev, staging, prod)" - type = string - validation { - condition = contains(["dev", "staging", "prod"], var.environment) - error_message = "Environment must be dev, staging, or prod." - } -} - -# Networking -variable "vpc_cidr" { - description = "VPC CIDR block" - type = string - default = "10.0.0.0/16" -} - -variable "availability_zones" { - description = "Availability zones" - type = list(string) - default = ["us-east-1a", "us-east-1b"] -} - -variable "public_subnet_cidrs" { - description = "Public subnet CIDR blocks" - type = list(string) - default = ["10.0.1.0/24", "10.0.2.0/24"] -} - -variable "private_subnet_cidrs" { - description = "Private subnet CIDR blocks" - type = list(string) - default = ["10.0.10.0/24", "10.0.11.0/24"] -} - -variable "database_subnet_cidrs" { - description = "Database subnet CIDR blocks" - type = list(string) - default = ["10.0.20.0/24", "10.0.21.0/24"] -} - -variable "app_port" { - description = "Application port" - type = number - default = 8000 -} - -# RDS Configuration -variable "db_name" { - description = "Database name" - type = string - default = "gamedb" -} - -variable "db_username" { - description = "Database master username" - type = string - default = "postgres" -} - -variable "db_engine_version" { - description = "PostgreSQL engine version" - type = string - default = "16.1" -} - -variable "db_instance_class" { - description = "Database instance class" - type = string - default = "db.t3.micro" -} - -variable "db_allocated_storage" { - description = "Allocated storage in GB" - type = number - default = 20 -} - -variable "db_max_allocated_storage" { - description = "Maximum allocated storage for auto-scaling" - type = number - default = 100 -} - -variable "backup_retention_days" { - description = "Backup retention period" - type = number - default = 30 -} - -variable "multi_az" { - description = "Enable Multi-AZ deployment" - type = bool - default = true -} - -# ECS Configuration -variable "ecr_repository_url" { - description = "ECR repository URL" - type = string -} - -variable "frontend_ecr_repository_url" { - description = "Frontend ECR repository URL (optional; defaults by replacing /backend with /frontend)" - type = string - default = "" -} - -variable "image_tag" { - description = "Docker image tag" - type = string - default = "latest" -} - -variable "frontend_image_tag" { - description = "Frontend Docker image tag (optional; defaults to image_tag)" - type = string - default = "" -} - -variable "frontend_port" { - description = "Frontend container port" - type = number - default = 4200 -} - -variable "task_cpu" { - description = "ECS task CPU units" - type = string - default = "512" -} - -variable "task_memory" { - description = "ECS task memory in MB" - type = string - default = "1024" -} - -variable "desired_count" { - description = "Desired number of ECS tasks" - type = number - default = 2 -} - -variable "frontend_desired_count" { - description = "Desired number of frontend ECS tasks" - type = number - default = 1 -} - -variable "min_capacity" { - description = "Minimum number of ECS tasks" - type = number - default = 2 -} - -variable "max_capacity" { - description = "Maximum number of ECS tasks" - type = number - default = 10 -} - -variable "target_cpu_utilization" { - description = "Target CPU utilization for auto-scaling" - type = number - default = 70 -} - -variable "target_memory_utilization" { - description = "Target memory utilization for auto-scaling" - type = number - default = 80 -} - -# ALB Configuration -variable "health_check_path" { - description = "ALB health check path" - type = string - default = "/health" -} - -variable "certificate_arn" { - description = "ACM certificate ARN for HTTPS" - type = string - default = "" -} - -# Security & JWT -variable "jwt_secret_key" { - description = "JWT secret key" - type = string - sensitive = true -} - -variable "jwt_algorithm" { - description = "JWT algorithm" - type = string - default = "HS256" -} - -variable "jwt_expire_minutes" { - description = "JWT expiration time in minutes" - type = number - default = 60 -} - -variable "enable_secret_rotation" { - description = "Enable Secrets Manager automatic rotation resources (requires Lambda rotation function integration)" - type = bool - default = false -} - -# Logging -variable "log_retention_days" { - description = "CloudWatch log retention in days" - type = number - default = 7 -} - -# Debug -variable "debug" { - description = "Enable debug mode" - type = bool - default = false -} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh deleted file mode 100755 index fa2ec19..0000000 --- a/scripts/bootstrap.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# bootstrap.sh — Terraform-based AWS bootstrap -# -# Applies infra/bootstrap to provision one-time account prerequisites: -# - S3 bucket for Terraform state -# - DynamoDB lock table (optional, compatibility mode) -# - ECR repositories (backend/frontend) -# - GitHub OIDC provider -# - GitHub Actions IAM role + inline policy -# -# Usage: -# bash scripts/bootstrap.sh -# -# Optional environment variables: -# AWS_REGION default: us-east-1 -# PROJECT_NAME default: mypythonproject1 -# GITHUB_ORG default: inferred from git remote origin -# GITHUB_REPO default: inferred from git remote origin -# GITHUB_ROLE_NAME default: GitHubActionsRole -# CREATE_LOCK_TABLE default: true (legacy compatibility) -# STATE_BUCKET_NAME default: terraform-state- -############################################################################### - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BOOTSTRAP_DIR="${ROOT_DIR}/infra/bootstrap" - -if ! command -v terraform >/dev/null 2>&1; then - echo "❌ terraform not found in PATH" - exit 1 -fi - -if ! command -v aws >/dev/null 2>&1; then - echo "❌ aws CLI not found in PATH" - exit 1 -fi - -AWS_REGION="${AWS_REGION:-us-east-1}" -PROJECT_NAME="${PROJECT_NAME:-mypythonproject1}" -GITHUB_ROLE_NAME="${GITHUB_ROLE_NAME:-GitHubActionsRole}" -CREATE_LOCK_TABLE="${CREATE_LOCK_TABLE:-true}" -STATE_BUCKET_NAME="${STATE_BUCKET_NAME:-}" - -infer_repo() { - local remote - remote="$(git -C "${ROOT_DIR}" config --get remote.origin.url 2>/dev/null || true)" - if [[ -z "${remote}" ]]; then - return 1 - fi - - remote="${remote%.git}" - remote="${remote#git@github.com:}" - remote="${remote#https://github.com/}" - - if [[ "${remote}" == *"/"* ]]; then - echo "${remote}" - return 0 - fi - - return 1 -} - -if [[ -z "${GITHUB_ORG:-}" || -z "${GITHUB_REPO:-}" ]]; then - if inferred="$(infer_repo)"; then - GITHUB_ORG="${GITHUB_ORG:-${inferred%/*}}" - GITHUB_REPO="${GITHUB_REPO:-${inferred#*/}}" - fi -fi - -if [[ -z "${GITHUB_ORG:-}" || -z "${GITHUB_REPO:-}" ]]; then - echo "❌ Unable to infer GITHUB_ORG/GITHUB_REPO. Set them explicitly and rerun." - echo " Example: GITHUB_ORG=my-org GITHUB_REPO=mypythonproject1 make bootstrap" - exit 1 -fi - -echo "🚀 Running Terraform bootstrap" -echo " Region: ${AWS_REGION}" -echo " Project: ${PROJECT_NAME}" -echo " Repo: ${GITHUB_ORG}/${GITHUB_REPO}" -echo " Role: ${GITHUB_ROLE_NAME}" -echo " Create lock table: ${CREATE_LOCK_TABLE}" - -terraform -chdir="${BOOTSTRAP_DIR}" init -upgrade - -tf_apply_args=( - -auto-approve - "-var=aws_region=${AWS_REGION}" - "-var=project_name=${PROJECT_NAME}" - "-var=github_org=${GITHUB_ORG}" - "-var=github_repo=${GITHUB_REPO}" - "-var=github_actions_role_name=${GITHUB_ROLE_NAME}" - "-var=create_lock_table=${CREATE_LOCK_TABLE}" -) - -if [[ -n "${STATE_BUCKET_NAME}" ]]; then - tf_apply_args+=("-var=state_bucket_name=${STATE_BUCKET_NAME}") -fi - -terraform -chdir="${BOOTSTRAP_DIR}" apply "${tf_apply_args[@]}" - -AWS_ACCOUNT_ID="$(terraform -chdir="${BOOTSTRAP_DIR}" output -raw aws_account_id)" -TF_STATE_BUCKET="$(terraform -chdir="${BOOTSTRAP_DIR}" output -raw terraform_state_bucket)" -TF_LOCK_TABLE="$(terraform -chdir="${BOOTSTRAP_DIR}" output -raw terraform_lock_table)" -ROLE_ARN="$(terraform -chdir="${BOOTSTRAP_DIR}" output -raw github_actions_role_arn)" - -echo -echo "✅ Bootstrap complete" -echo -echo "Set GitHub secrets:" -echo " gh secret set AWS_ROLE_TO_ASSUME --body \"${ROLE_ARN}\"" -echo " gh secret set TERRAFORM_STATE_BUCKET --body \"${TF_STATE_BUCKET}\"" -if [[ -n "${TF_LOCK_TABLE}" ]]; then - echo " gh secret set TERRAFORM_LOCK_TABLE --body \"${TF_LOCK_TABLE}\"" -fi -echo -echo "Context:" -echo " AWS account: ${AWS_ACCOUNT_ID}" -echo " AWS region: ${AWS_REGION}" -echo " State bucket:${TF_STATE_BUCKET}" -if [[ -n "${TF_LOCK_TABLE}" ]]; then - echo " Lock table: ${TF_LOCK_TABLE}" -else - echo " Lock table: (disabled)" -fi diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh deleted file mode 100755 index 3a16c91..0000000 --- a/scripts/setup-env.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# setup-env.sh — Export environment variables for Terraform and deployments -# -# Resolves the correct S3 state bucket, DynamoDB lock table, ECR registry, -# and ECS cluster / service names for the requested environment and exports -# them into the current process. -# -# Important: to propagate exports into your calling shell, source this script -# rather than executing it in a sub-shell: -# -# source scripts/setup-env.sh # interactive use -# eval "$(ENV=staging bash scripts/setup-env.sh 2>/dev/null)" # in scripts -# -# Usage: -# ENV=staging source scripts/setup-env.sh -# make setup-env ENV=staging -# -# Environment variables: -# ENV REQUIRED — target environment: dev | staging | prod | production -# AWS_REGION optional — AWS region (default: us-east-1) -# AWS_ACCOUNT_ID optional — used to build ECR registry URL -# TERRAFORM_STATE_BUCKET optional — override default S3 bucket name -# TERRAFORM_LOCK_TABLE optional — (deprecated) DynamoDB table name -# -# Exports set by this script: -# AWS_REGION, TF_ROOT, TF_VAR_environment, TF_VAR_aws_region, -# TERRAFORM_STATE_BUCKET, TERRAFORM_LOCK_TABLE, -# ECR_REGISTRY, ECR_REPOSITORY_BACKEND, ECR_REPOSITORY_FRONTEND, -# ECS_CLUSTER, ECS_SERVICE_BACKEND, ECS_SERVICE_FRONTEND -# -# Dependencies: bash 4+ -# Caller(s): make setup-env / source before tf-plan, tf-apply, ecs-deploy -############################################################################### - -set -euo pipefail - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# ============================================================================ -# Configuration -# ============================================================================ - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROJECT_ROOT="${SCRIPT_DIR}" - -# Determine environment -ENV="${ENV:-}" -if [ -z "$ENV" ]; then - echo -e "${YELLOW}⚠️ ENV not set. Skipping setup.${NC}" - exit 0 -fi - -# Normalize aliases -if [ "$ENV" = "production" ]; then - ENV="prod" -fi - -# Validate environment -if [[ ! "$ENV" =~ ^(dev|staging|prod)$ ]]; then - echo -e "${RED}❌ Invalid ENV: $ENV. Must be dev, staging, prod, or production${NC}" - exit 1 -fi - -echo -e "${BLUE}📝 Setting up environment for: ${ENV}${NC}" - -# ============================================================================ -# AWS Configuration -# ============================================================================ - -# Set AWS region -AWS_REGION="${AWS_REGION:-us-east-1}" -export AWS_REGION - -echo -e "${GREEN}✓${NC} AWS_REGION: $AWS_REGION" - -# ============================================================================ -# Terraform Configuration -# ============================================================================ - -export TF_ROOT="${PROJECT_ROOT}/infra" -export TF_VAR_environment="${ENV}" -export TF_VAR_aws_region="${AWS_REGION}" - -# Terraform state backend configuration -if [ -z "${AWS_ACCOUNT_ID:-}" ]; then - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true) -fi - -if [ -n "${AWS_ACCOUNT_ID:-}" ]; then - TERRAFORM_STATE_BUCKET="${TERRAFORM_STATE_BUCKET:-terraform-state-${AWS_ACCOUNT_ID}}" -else - TERRAFORM_STATE_BUCKET="${TERRAFORM_STATE_BUCKET:-mypythonproject1-tf-state-${ENV}}" -fi -TERRAFORM_LOCK_TABLE="${TERRAFORM_LOCK_TABLE:-terraform-locks}" - -export TERRAFORM_STATE_BUCKET -export TERRAFORM_LOCK_TABLE -export TF_VAR_state_bucket="${TERRAFORM_STATE_BUCKET}" -export TF_VAR_lock_table="${TERRAFORM_LOCK_TABLE}" - -echo -e "${GREEN}✓${NC} TF_ROOT: $TF_ROOT" -echo -e "${GREEN}✓${NC} Terraform State Bucket: $TERRAFORM_STATE_BUCKET" -echo -e "${GREEN}✓${NC} Terraform Lock Table (deprecated): $TERRAFORM_LOCK_TABLE" - -# ============================================================================ -# Docker Configuration -# ============================================================================ - -# ECR Registry -if [ -z "${AWS_ACCOUNT_ID:-}" ]; then - echo -e "${YELLOW}⚠️ AWS_ACCOUNT_ID not detected; ECR registry may be incomplete${NC}" -fi - -if [ -n "${AWS_ACCOUNT_ID:-}" ]; then - ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - ECR_REPOSITORY_BACKEND="${ECR_REGISTRY}/mypythonproject1/backend" - ECR_REPOSITORY_FRONTEND="${ECR_REGISTRY}/mypythonproject1/frontend" -else - ECR_REGISTRY="" - ECR_REPOSITORY_BACKEND="" - ECR_REPOSITORY_FRONTEND="" -fi - -export ECR_REGISTRY -export ECR_REPOSITORY_BACKEND -export ECR_REPOSITORY_FRONTEND - -echo -e "${GREEN}✓${NC} ECR_REGISTRY: ${ECR_REGISTRY:-}" - -# ============================================================================ -# ECS Configuration -# ============================================================================ - -case "$ENV" in - staging) - ECS_CLUSTER="mypythonproject1-cluster-staging" - ECS_SERVICE_BACKEND="backend-service-staging" - ECS_SERVICE_FRONTEND="frontend-service-staging" - ;; - prod) - ECS_CLUSTER="mypythonproject1-cluster-prod" - ECS_SERVICE_BACKEND="backend-service-prod" - ECS_SERVICE_FRONTEND="frontend-service-prod" - ;; - dev) - ECS_CLUSTER="mypythonproject1-cluster-dev" - ECS_SERVICE_BACKEND="backend-service-dev" - ECS_SERVICE_FRONTEND="frontend-service-dev" - ;; -esac - -export ECS_CLUSTER -export ECS_SERVICE_BACKEND -export ECS_SERVICE_FRONTEND - -echo -e "${GREEN}✓${NC} ECS_CLUSTER: $ECS_CLUSTER" -echo -e "${GREEN}✓${NC} ECS_SERVICE_BACKEND: $ECS_SERVICE_BACKEND" -echo -e "${GREEN}✓${NC} ECS_SERVICE_FRONTEND: $ECS_SERVICE_FRONTEND" - -# ============================================================================ -# Secrets from GitHub Secrets (if running in CI/CD) -# ============================================================================ - -if [ -n "${GITHUB_ACTIONS:-}" ]; then - echo -e "${BLUE}📝 Loading GitHub Secrets...${NC}" - - # AWS credentials (OIDC or stored credentials) - export AWS_ROLE_TO_ASSUME="${AWS_ROLE_TO_ASSUME:-}" - - # Database secrets - export TF_VAR_db_username="${DB_USERNAME:-}" - export TF_VAR_db_password="${DB_PASSWORD:-}" - - # Application secrets - export TF_VAR_jwt_secret_key="${JWT_SECRET_KEY:-}" - export TF_VAR_jwt_algorithm="${JWT_ALGORITHM:-HS256}" - - echo -e "${GREEN}✓${NC} GitHub Secrets loaded" -fi - -# ============================================================================ -# Display Configuration Summary -# ============================================================================ - -echo "" -echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Environment Configuration Summary (${ENV})${NC}" -echo -e "${BLUE}╠════════════════════════════════════════════════════════════╣${NC}" -echo -e "${BLUE}║${NC} AWS Region: ${AWS_REGION}" -echo -e "${BLUE}║${NC} Terraform Root: ${TF_ROOT}" -echo -e "${BLUE}║${NC} State Backend: ${TERRAFORM_STATE_BUCKET}" -echo -e "${BLUE}║${NC} State Lock Table: ${TERRAFORM_LOCK_TABLE}" -echo -e "${BLUE}║${NC} ECR Registry: ${ECR_REGISTRY}" -echo -e "${BLUE}║${NC} ECS Cluster: ${ECS_CLUSTER}" -echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -echo -e "${GREEN}✅ Environment setup complete for ${ENV}${NC}" diff --git a/scripts/terraform-apply.sh b/scripts/terraform-apply.sh deleted file mode 100755 index 6820c4e..0000000 --- a/scripts/terraform-apply.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# terraform-apply.sh — Apply Terraform changes from a saved plan -# -# Initialises Terraform against the remote S3 backend and applies the binary -# plan produced by terraform-plan.sh. If no plan file exists the plan script -# is invoked automatically first. -# -# Production safeguard: local production execution is disabled. Production -# changes must run through GitHub Actions workflows. -# -# After a successful apply, Terraform outputs are exported to -# /tmp/tf-outputs-.json. If jq is available, ECS_CLUSTER and -# ALB_ENDPOINT are also extracted and exported into the current shell. -# -# Usage: -# ENV=staging bash scripts/terraform-apply.sh -# make tf-apply ENV=staging -# -# Environment variables: -# ENV REQUIRED — target environment: dev | staging -# TERRAFORM_STATE_BUCKET REQUIRED — S3 bucket holding Terraform state -# AWS_REGION REQUIRED — AWS region -# TF_ROOT optional — Terraform directory (default: ./infra) -# -# Dependencies: terraform; jq (optional — used to parse TF outputs) -# Caller(s): make tf-apply -############################################################################### - -set -euo pipefail - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# ============================================================================ -# Validation -# ============================================================================ - -if [ -z "${ENV:-}" ]; then - echo -e "${RED}❌ ENV not set${NC}" - exit 1 -fi - -if [[ "${ENV}" =~ ^(prod|production)$ ]]; then - echo -e "${RED}❌ Local production runs are disabled.${NC}" - echo -e "${YELLOW}Use GitHub Actions release workflow for production infrastructure changes.${NC}" - exit 1 -fi - -echo -e "${BLUE}🚀 Terraform Apply for ${ENV}${NC}" - -# ============================================================================ -# Setup -# ============================================================================ - -TF_ROOT="${TF_ROOT:-./infra}" -TFPLAN_FILE="/tmp/tf-plans/tfplan.${ENV}" -TFVARS_FILE="${TF_ROOT}/envs/${ENV}.tfvars" - -# Check if plan exists from terraform-plan.sh -if [ ! -f "$TFPLAN_FILE" ]; then - echo -e "${YELLOW}⚠️ Plan not found: $TFPLAN_FILE${NC}" - echo " Running terraform plan first..." - bash scripts/terraform-plan.sh -fi - -# ============================================================================ -# Terraform Backend Configuration -# ============================================================================ - -echo -e "${BLUE}📝 Configuring Terraform backend...${NC}" - -cat > "${TF_ROOT}/backend-config.hcl" << EOF -bucket = "${TERRAFORM_STATE_BUCKET}" -key = "terraform/${ENV}/terraform.tfstate" -region = "${AWS_REGION}" -use_lockfile = true -encrypt = true -EOF - -# ============================================================================ -# Terraform Initialization -# ============================================================================ - -echo -e "${BLUE}📋 Initializing Terraform...${NC}" -terraform -chdir="${TF_ROOT}" init \ - -backend-config="backend-config.hcl" \ - -upgrade - -# ============================================================================ -# Terraform Apply -# ============================================================================ - -echo -e "${BLUE}🔄 Applying Terraform changes...${NC}" -terraform -chdir="${TF_ROOT}" apply \ - -input=false \ - -lock=true \ - -lock-timeout=5m \ - "${TFPLAN_FILE}" - -echo -e "${GREEN}✓${NC} Terraform apply completed" - -# ============================================================================ -# Export Outputs -# ============================================================================ - -echo -e "${BLUE}📤 Exporting Terraform outputs...${NC}" - -# Create output file -OUTPUT_FILE="/tmp/tf-outputs-${ENV}.json" -terraform -chdir="${TF_ROOT}" output -json > "${OUTPUT_FILE}" - -echo -e "${GREEN}✓${NC} Outputs exported to: ${OUTPUT_FILE}" - -# Extract key outputs -if command -v jq &> /dev/null; then - ECS_CLUSTER=$(jq -r '.ecs_cluster.value // empty' "${OUTPUT_FILE}") - ALB_ENDPOINT=$(jq -r '.alb_endpoint.value // empty' "${OUTPUT_FILE}") - - if [ -n "$ECS_CLUSTER" ]; then - export ECS_CLUSTER - echo -e "${BLUE}║${NC} ECS Cluster: ${ECS_CLUSTER}" - fi - - if [ -n "$ALB_ENDPOINT" ]; then - export ALB_ENDPOINT - echo -e "${BLUE}║${NC} ALB Endpoint: ${ALB_ENDPOINT}" - fi -fi - -# ============================================================================ -# Summary -# ============================================================================ - -echo "" -echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Terraform Apply Summary${NC}" -echo -e "${BLUE}╠════════════════════════════════════════════════════════════╣${NC}" -echo -e "${BLUE}║${NC} Environment: ${ENV}" -echo -e "${BLUE}║${NC} State Backend: ${TERRAFORM_STATE_BUCKET}/${ENV}" -echo -e "${BLUE}║${NC} Status: ✅ Applied" -echo -e "${BLUE}║${NC} Outputs File: ${OUTPUT_FILE}" -echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -echo -e "${GREEN}✅ Terraform apply complete${NC}" -echo -e "${YELLOW}📝 Next step: make ecs-deploy ENV=${ENV} IMAGE_TAG=...${NC}" diff --git a/scripts/terraform-destroy.sh b/scripts/terraform-destroy.sh deleted file mode 100755 index 4686b60..0000000 --- a/scripts/terraform-destroy.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# terraform-destroy.sh — Safely destroy Terraform-managed infrastructure -# -# ⚠️ THIS ACTION IS IRREVERSIBLE. Use with extreme caution on production. -# -# Requires the operator to type "destroy " at an interactive prompt -# before any resources are removed. This prevents accidental teardown from -# typos, automation, or copy-paste errors. -# -# Steps: -# 1. Validates ENV is set and the matching .tfvars file exists -# 2. Prompts for explicit confirmation ("destroy ") -# 3. Writes a backend-config.hcl pointing at the correct S3 state -# 4. Runs terraform init then terraform destroy -auto-approve -# -# Usage: -# ENV=staging bash scripts/terraform-destroy.sh -# make tf-destroy ENV=staging -# -# Environment variables: -# ENV REQUIRED — target environment: dev | staging -# TERRAFORM_STATE_BUCKET REQUIRED — S3 bucket holding Terraform state -# AWS_REGION REQUIRED — AWS region -# TF_ROOT optional — Terraform directory (default: ./infra) -# -# Dependencies: terraform -# Caller(s): make tf-destroy -############################################################################### - -set -euo pipefail - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# ============================================================================ -# Validation -# ============================================================================ - -if [ -z "${ENV:-}" ]; then - echo -e "${RED}❌ ENV not set${NC}" - exit 1 -fi - -if [[ "${ENV}" =~ ^(prod|production)$ ]]; then - echo -e "${RED}❌ Local production runs are disabled.${NC}" - echo -e "${YELLOW}Use GitHub Actions release workflow for production infrastructure changes.${NC}" - exit 1 -fi - -# ============================================================================ -# Confirmation -# ============================================================================ - -echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${RED}║ ⚠️ DANGER ZONE ⚠️${NC}" -echo -e "${RED}╠════════════════════════════════════════════════════════════╣${NC}" -echo -e "${RED}║ This will DESTROY all infrastructure in: ${ENV}${NC}" -echo -e "${RED}║ This action CANNOT be undone!${NC}" -echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -read -p "Type 'destroy ${ENV}' to confirm: " confirm -if [ "$confirm" != "destroy ${ENV}" ]; then - echo -e "${YELLOW}❌ Destroy cancelled${NC}" - exit 1 -fi - -echo "" -echo -e "${RED}🗑️ Destroying infrastructure for ${ENV}${NC}" - -# ============================================================================ -# Setup -# ============================================================================ - -TF_ROOT="${TF_ROOT:-./infra}" -TFVARS_FILE="${TF_ROOT}/envs/${ENV}.tfvars" - -if [ ! -f "$TFVARS_FILE" ]; then - echo -e "${RED}❌ Variables file not found: $TFVARS_FILE${NC}" - exit 1 -fi - -# ============================================================================ -# Terraform Backend Configuration -# ============================================================================ - -echo -e "${BLUE}📝 Configuring Terraform backend...${NC}" - -cat > "${TF_ROOT}/backend-config.hcl" << EOF -bucket = "${TERRAFORM_STATE_BUCKET}" -key = "terraform/${ENV}/terraform.tfstate" -region = "${AWS_REGION}" -use_lockfile = true -encrypt = true -EOF - -# ============================================================================ -# Terraform Initialization -# ============================================================================ - -echo -e "${BLUE}📋 Initializing Terraform...${NC}" -terraform -chdir="${TF_ROOT}" init \ - -backend-config="backend-config.hcl" \ - -upgrade - -# ============================================================================ -# Terraform Destroy -# ============================================================================ - -echo -e "${BLUE}🗑️ Running Terraform destroy...${NC}" -terraform -chdir="${TF_ROOT}" destroy \ - -var-file="envs/${ENV}.tfvars" \ - -auto-approve - -echo -e "${GREEN}✓${NC} Terraform destroy completed" - -# ============================================================================ -# Summary -# ============================================================================ - -echo "" -echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Terraform Destroy Summary${NC}" -echo -e "${BLUE}╠════════════════════════════════════════════════════════════╣${NC}" -echo -e "${BLUE}║${NC} Environment: ${ENV}" -echo -e "${BLUE}║${NC} State Backend: ${TERRAFORM_STATE_BUCKET}/${ENV}" -echo -e "${BLUE}║${NC} Status: ✅ Destroyed" -echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -echo -e "${GREEN}✅ Terraform destroy complete${NC}" diff --git a/scripts/terraform-plan.sh b/scripts/terraform-plan.sh deleted file mode 100755 index c885a6e..0000000 --- a/scripts/terraform-plan.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# terraform-plan.sh — Terraform plan with optional IaC security scan -# -# Initialises Terraform against the remote S3 backend, generates a binary -# plan for the requested environment, and optionally runs Checkov against -# the Terraform source for security best-practice violations. -# -# The compiled plan file is saved to /tmp/tf-plans/tfplan. and is -# consumed automatically by terraform-apply.sh. -# -# Usage: -# ENV=staging bash scripts/terraform-plan.sh -# make tf-plan ENV=staging -# -# Environment variables: -# ENV REQUIRED — target environment: dev | staging -# TERRAFORM_STATE_BUCKET REQUIRED — S3 bucket holding Terraform state -# AWS_REGION REQUIRED — AWS region -# TF_ROOT optional — Terraform directory (default: ./infra) -# -# Dependencies: terraform; checkov (optional — skipped if not on PATH) -# Caller(s): make tf-plan / called automatically by terraform-apply.sh -############################################################################### - -set -euo pipefail - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# ============================================================================ -# Validation -# ============================================================================ - -if [ -z "${ENV:-}" ]; then - echo -e "${RED}❌ ENV not set${NC}" - exit 1 -fi - -if [[ "${ENV}" =~ ^(prod|production)$ ]]; then - echo -e "${RED}❌ Local production runs are disabled.${NC}" - echo -e "${YELLOW}Use GitHub Actions release workflow for production infrastructure changes.${NC}" - exit 1 -fi - -echo -e "${BLUE}🏗️ Terraform Plan for ${ENV}${NC}" - -# ============================================================================ -# Setup -# ============================================================================ - -TF_ROOT="${TF_ROOT:-./infra}" -TFVARS_FILE="${TF_ROOT}/envs/${ENV}.tfvars" -TFPLAN_FILE="/tmp/tfplan.${ENV}" - -if [ ! -f "$TFVARS_FILE" ]; then - echo -e "${RED}❌ Variables file not found: $TFVARS_FILE${NC}" - exit 1 -fi - -# Provide safe local defaults for dev-only planning when required vars are unset. -if [ "$ENV" = "dev" ]; then - if [ -z "${TF_VAR_ecr_repository_url:-}" ]; then - AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:-}" - if [ -z "$AWS_ACCOUNT_ID" ]; then - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true) - fi - if [ -n "$AWS_ACCOUNT_ID" ]; then - export TF_VAR_ecr_repository_url="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mypythonproject1/backend" - echo -e "${YELLOW}⚠️ TF_VAR_ecr_repository_url not set; using dev default: ${TF_VAR_ecr_repository_url}${NC}" - fi - fi - - if [ -z "${TF_VAR_jwt_secret_key:-}" ]; then - export TF_VAR_jwt_secret_key="dev-local-jwt-secret-change-me" - echo -e "${YELLOW}⚠️ TF_VAR_jwt_secret_key not set; using dev-only placeholder value${NC}" - fi -fi - -if [ -z "${TF_VAR_ecr_repository_url:-}" ]; then - echo -e "${RED}❌ Missing required TF_VAR_ecr_repository_url${NC}" - echo -e "${YELLOW}Set it explicitly (for non-dev): export TF_VAR_ecr_repository_url=.dkr.ecr..amazonaws.com/mypythonproject1/backend${NC}" - exit 1 -fi - -if [ -z "${TF_VAR_jwt_secret_key:-}" ]; then - echo -e "${RED}❌ Missing required TF_VAR_jwt_secret_key${NC}" - echo -e "${YELLOW}Set it explicitly (for non-dev): export TF_VAR_jwt_secret_key=${NC}" - exit 1 -fi - -# ============================================================================ -# Terraform Backend Configuration -# ============================================================================ - -echo -e "${BLUE}📝 Configuring Terraform backend...${NC}" - -cat > "${TF_ROOT}/backend-config.hcl" << EOF -bucket = "${TERRAFORM_STATE_BUCKET}" -key = "terraform/${ENV}/terraform.tfstate" -region = "${AWS_REGION}" -use_lockfile = true -encrypt = true -EOF - -echo -e "${GREEN}✓${NC} Backend configured" - -# ============================================================================ -# Terraform Initialization -# ============================================================================ - -echo -e "${BLUE}📋 Initializing Terraform...${NC}" -terraform -chdir="${TF_ROOT}" init \ - -backend-config="backend-config.hcl" \ - -upgrade - -echo -e "${GREEN}✓${NC} Terraform initialized" - -# ============================================================================ -# Terraform Plan -# ============================================================================ - -echo -e "${BLUE}📋 Running Terraform plan...${NC}" -terraform -chdir="${TF_ROOT}" plan \ - -var-file="envs/${ENV}.tfvars" \ - -out="${TFPLAN_FILE}" \ - -input=false - -echo -e "${GREEN}✓${NC} Terraform plan generated: ${TFPLAN_FILE}" - -# ============================================================================ -# IaC Security Scanning (Checkov) -# ============================================================================ - -if command -v checkov &> /dev/null; then - echo -e "${BLUE}🔒 Running Checkov IaC security scan...${NC}" - checkov -d "${TF_ROOT}" \ - --framework terraform \ - --compact \ - --quiet || true - echo -e "${GREEN}✓${NC} Checkov scan completed" -else - echo -e "${YELLOW}⚠️ Checkov not installed, skipping security scan${NC}" -fi - -# ============================================================================ -# Plan Summary -# ============================================================================ - -echo "" -echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Terraform Plan Summary${NC}" -echo -e "${BLUE}╠════════════════════════════════════════════════════════════╣${NC}" -echo -e "${BLUE}║${NC} Environment: ${ENV}" -echo -e "${BLUE}║${NC} Variables File: envs/${ENV}.tfvars" -echo -e "${BLUE}║${NC} Plan Output: ${TFPLAN_FILE}" -echo -e "${BLUE}║${NC} State Backend: ${TERRAFORM_STATE_BUCKET}/${ENV}" -echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -# Save plan artifact for CI/CD -mkdir -p "/tmp/tf-plans" -cp "${TFPLAN_FILE}" "/tmp/tf-plans/tfplan.${ENV}" - -echo -e "${GREEN}✅ Terraform plan complete${NC}" -echo -e "${YELLOW}📝 Next step: make tf-apply ENV=${ENV}${NC}" diff --git a/scripts/terraform-validate.sh b/scripts/terraform-validate.sh deleted file mode 100755 index 6738e91..0000000 --- a/scripts/terraform-validate.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -############################################################################### -# terraform-validate.sh — Terraform format, init, and validate -# -# Runs the three validation steps that require no remote state and no AWS -# credentials, making this safe to call without an active AWS session: -# -# 1. terraform fmt --check --recursive formatting diff (exits 1 if dirty) -# 2. terraform init -backend=false provider + module resolution -# 3. terraform validate configuration syntax check -# 4. tflint (if installed) linting rules -# -# Usage: -# bash scripts/terraform-validate.sh -# make tf-validate -# -# Environment variables: -# PROJECT_ROOT optional — repo root; TF_ROOT defaults to ./infra -# -# Dependencies: terraform; tflint (optional — skipped if not on PATH) -# Caller(s): make tf-validate / .github/actions/terraform/validate -############################################################################### - -set -euo pipefail - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}🔍 Running Terraform validation...${NC}" - -TF_ROOT="${PROJECT_ROOT:-./infra}" - -# ============================================================================ -# Terraform Format Check -# ============================================================================ - -echo -e "${BLUE}📋 Checking Terraform format...${NC}" -# Clear previous backend metadata so stale remote backend params (e.g. deprecated -# dynamodb_table) do not leak warnings into local validation-only runs. -rm -rf "${TF_ROOT}/.terraform" -if terraform -chdir="${TF_ROOT}" fmt -check -recursive .; then - echo -e "${GREEN}✓${NC} Terraform format is correct" -else - echo -e "${YELLOW}⚠️ Terraform format issues found${NC}" - echo " Run 'terraform fmt -recursive' to fix" -fi - -# ============================================================================ -# Terraform Initialization (no backend) -# ============================================================================ - -echo -e "${BLUE}📋 Initializing Terraform (no backend)...${NC}" -terraform -chdir="${TF_ROOT}" init -backend=false -upgrade - -# ============================================================================ -# Terraform Validation -# ============================================================================ - -echo -e "${BLUE}📋 Validating Terraform configuration...${NC}" -if terraform -chdir="${TF_ROOT}" validate; then - echo -e "${GREEN}✓${NC} Terraform validation passed" -else - echo -e "${RED}❌ Terraform validation failed${NC}" - exit 1 -fi - -# ============================================================================ -# tflint Validation -# ============================================================================ - -echo -e "${BLUE}📋 Running tflint...${NC}" -if command -v tflint &> /dev/null; then - cd "${TF_ROOT}" && tflint --init && tflint - echo -e "${GREEN}✓${NC} tflint checks completed" -else - echo -e "${YELLOW}⚠️ tflint not installed, skipping${NC}" -fi - -echo -e "${GREEN}✅ Terraform validation complete${NC}"