diff --git a/.github/actions/aws-auth/action.yml b/.github/actions/aws-auth/action.yml index 7f9940b..022773c 100644 --- a/.github/actions/aws-auth/action.yml +++ b/.github/actions/aws-auth/action.yml @@ -47,6 +47,8 @@ runs: aws-region: ${{ inputs.aws-region }} role-session-name: ${{ inputs.session-name }} role-duration-seconds: ${{ inputs.duration-seconds }} + audience: sts.amazonaws.com + unset-current-credentials: true - name: Get AWS Account ID id: aws-account diff --git a/.github/actions/deploy-env/action.yml b/.github/actions/deploy-env/action.yml index a1c86a8..ce50e19 100644 --- a/.github/actions/deploy-env/action.yml +++ b/.github/actions/deploy-env/action.yml @@ -26,6 +26,9 @@ inputs: gitops-repo: description: "Owner/name of the GitOps repository" required: true + target-environment: + description: "EKs env" + required: true # ── K8s / AWS ───────────────────────────────────────────────────────────── eks-cluster-name: @@ -101,33 +104,9 @@ inputs: runs: using: "composite" steps: - # ── 1. Update GitOps repo ─────────────────────────────────────────────── - - name: "Checkout GitOps repo" - uses: actions/checkout@v4 - with: - repository: ${{ inputs.gitops-repo }} - token: ${{ inputs.gitops-deploy-token }} - path: _gitops - - - name: "Patch image tag (${{ inputs.gitops-env }} → ${{ inputs.image-tag }})" - shell: bash - run: | - docker run --rm --user root -v "$PWD/_gitops:/gitops" mikefarah/yq:4 \ - e '.backend.image.tag = "${{ inputs.image-tag }}" | .frontend.image.tag = "${{ inputs.image-tag }}"' \ - -i /gitops/environments/${{ inputs.gitops-env }}/values.yaml - - - name: "Commit & push GitOps change" - shell: bash - run: | - cd _gitops - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add environments/${{ inputs.gitops-env }}/values.yaml - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "chore(deploy): ${{ inputs.gitops-env }} image tag ${{ inputs.image-tag }} [skip ci]" - git push origin main - - # ── 2. Authenticate with AWS ──────────────────────────────────────────── + # ── 1. Authenticate with AWS & write K8s secret FIRST ────────────────── + # Must happen before the gitops commit so that pods starting in response + # to the ArgoCD sync always find a secret with the correct DATABASE_HOST. - name: "Authenticate with AWS" uses: ./.github/actions/aws-auth with: @@ -141,7 +120,7 @@ runs: --name ${{ inputs.eks-cluster-name }} \ --region ${{ inputs.aws-region }} - # ── 3. Upsert K8s DB secret ───────────────────────────────────────────── + # ── 2. Upsert K8s DB secret ───────────────────────────────────────────── - name: "Upsert DB credentials secret" shell: bash run: | @@ -154,6 +133,34 @@ runs: --from-literal=password=${{ inputs.db-password }} \ --dry-run=client -o yaml | kubectl apply -f - + # ── 3. Update GitOps repo (triggers ArgoCD sync) ──────────────────────── + # K8s secret is already correct above — pods that start from this sync + # will find DATABASE_HOST populated immediately. + - name: "Checkout GitOps repo" + uses: actions/checkout@v4 + with: + repository: ${{ inputs.gitops-repo }} + token: ${{ inputs.gitops-deploy-token }} + path: _gitops + + - name: "Patch image tag (${{ inputs.gitops-env }} → ${{ inputs.image-tag }})" + shell: bash + run: | + docker run --rm --user root -v "$PWD/_gitops:/gitops" mikefarah/yq:4 \ + e '.backend.image.tag = "${{ inputs.image-tag }}" | .frontend.image.tag = "${{ inputs.image-tag }}"' \ + -i /gitops/environments/${{ inputs.target-environment }}/values.yaml + + - name: "Commit & push GitOps change" + shell: bash + run: | + cd _gitops + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add environments/${{ inputs.target-environment }}/values.yaml + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore(deploy): ${{ inputs.gitops-env }} image tag ${{ inputs.image-tag }} [skip ci]" + git push origin main + # ── 4. Wait for ArgoCD Healthy + Synced ──────────────────────────────── - name: "Wait for ArgoCD sync (${{ inputs.argocd-app-name }})" shell: bash diff --git a/.github/workflows/cd-ecs.yml b/.github/workflows/cd-ecs.yml index ad525f0..03ea4a2 100644 --- a/.github/workflows/cd-ecs.yml +++ b/.github/workflows/cd-ecs.yml @@ -39,8 +39,18 @@ on: description: "Target environment" required: true type: choice - options: [staging, production] + options: [staging, prod] default: staging + operation: + description: "Operation to run" + required: true + type: choice + options: [deploy, rollback] + default: deploy + rollback_tag: + description: "Required when operation=rollback (for example: staging-a1b2c3d or v1.2.3)" + required: false + type: string permissions: contents: read @@ -68,9 +78,9 @@ jobs: timeout-minutes: 20 if: > (github.event_name == 'workflow_run' - && github.event.workflow_run.conclusion == 'success') - || (github.event_name == 'workflow_dispatch' - && inputs.environment == 'staging') + && github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch' + && inputs.environment == 'staging' + && inputs.operation != 'rollback') environment: staging strategy: matrix: @@ -149,6 +159,92 @@ jobs: echo "Waiting 10 minutes for Terraform apply to complete..." sleep 600 + # --------------------------------------------------------------------------- + # 1b2. Trigger Ansible deploy on EC2 infra repo (staging) + # --------------------------------------------------------------------------- + ec2-deploy-staging: + name: "EC2 Deploy [staging] via Ansible" + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [build-staging] + environment: staging + steps: + - name: Dispatch ansible-ec2-deploy to infra3 repo + id: dispatch_ec2_staging + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const headSha = (context.payload.workflow_run && context.payload.workflow_run.head_sha) || context.sha; + const imageTag = `staging-${headSha.substring(0, 7)}`; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: 'mypythonproject1-infra3', + workflow_id: 'ansible-ec2-deploy.yml', + ref: 'main', + inputs: { + environment: 'staging', + operation: 'deploy', + image_tag: imageTag, + }, + }); + console.log(`Dispatched ansible-ec2-deploy on mypythonproject1-infra3 (staging, tag=${imageTag})`); + core.setOutput('image_tag', imageTag); + + ec2-verify-staging: + name: "Verify EC2 Deploy [staging]" + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [ec2-deploy-staging] + environment: staging + steps: + - name: Wait for infra3 ansible workflow result + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = 'mypythonproject1-infra3'; + const expectedTag = `${((context.payload.workflow_run && context.payload.workflow_run.head_sha) || context.sha).substring(0, 7)}`; + const tagNeedle = `tag=staging-${expectedTag}`; + const deadline = Date.now() + 30 * 60 * 1000; + + let matchedRun = null; + while (Date.now() < deadline) { + const resp = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'ansible-ec2-deploy.yml', + event: 'workflow_dispatch', + per_page: 20, + }); + + matchedRun = resp.data.workflow_runs.find((r) => { + const title = r.display_title || ''; + return title.includes('[staging]') && title.includes(tagNeedle); + }); + + if (!matchedRun) { + await new Promise((resolve) => setTimeout(resolve, 15000)); + continue; + } + + if (matchedRun.status !== 'completed') { + await new Promise((resolve) => setTimeout(resolve, 15000)); + continue; + } + + if (matchedRun.conclusion !== 'success') { + core.setFailed(`infra3 ansible deploy failed: ${matchedRun.html_url}`); + return; + } + + core.notice(`infra3 ansible deploy succeeded: ${matchedRun.html_url}`); + return; + } + + core.setFailed('Timed out waiting for infra3 ansible staging deployment run to complete'); + # --------------------------------------------------------------------------- # 1c. ECS rolling deploy (staging) # --------------------------------------------------------------------------- @@ -254,8 +350,10 @@ jobs: name: "Build & Push [production] (${{ matrix.service }})" runs-on: ubuntu-latest timeout-minutes: 20 - if: startsWith(github.ref, 'refs/tags/v') - environment: production + if: > + startsWith(github.ref, 'refs/tags/v') || + (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod' && inputs.operation != 'rollback') + environment: prod strategy: matrix: service: [backend, frontend] @@ -315,7 +413,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 needs: [build-production] - environment: production + environment: prod steps: - name: Dispatch terraform-apply to infra repo uses: actions/github-script@v7 @@ -340,6 +438,191 @@ jobs: echo "Waiting 15 minutes for Terraform apply to complete..." sleep 900 + # --------------------------------------------------------------------------- + # 2c2. Trigger Ansible deploy on EC2 infra repo (production) + # --------------------------------------------------------------------------- + ec2-deploy-production: + name: "EC2 Deploy [production] via Ansible" + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [build-production] + environment: prod + steps: + - name: Dispatch ansible-ec2-deploy to infra3 repo + id: dispatch_ec2_prod + 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-infra3', + workflow_id: 'ansible-ec2-deploy.yml', + ref: 'main', + inputs: { + environment: 'prod', + operation: 'deploy', + image_tag: tag, + production_confirmation: 'APPROVE_PROD_DEPLOY', + }, + }); + console.log(`Dispatched ansible-ec2-deploy on mypythonproject1-infra3 (prod, tag=${tag})`); + core.setOutput('image_tag', tag); + + ec2-verify-production: + name: "Verify EC2 Deploy [production]" + runs-on: ubuntu-latest + timeout-minutes: 45 + needs: [ec2-deploy-production] + environment: prod + steps: + - name: Wait for infra3 ansible workflow result + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = 'mypythonproject1-infra3'; + const tag = context.ref.replace('refs/tags/', ''); + const tagNeedle = `tag=${tag}`; + const deadline = Date.now() + 45 * 60 * 1000; + + let matchedRun = null; + while (Date.now() < deadline) { + const resp = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'ansible-ec2-deploy.yml', + event: 'workflow_dispatch', + per_page: 20, + }); + + matchedRun = resp.data.workflow_runs.find((r) => { + const title = r.display_title || ''; + return title.includes('[prod]') && title.includes(tagNeedle); + }); + + if (!matchedRun) { + await new Promise((resolve) => setTimeout(resolve, 20000)); + continue; + } + + if (matchedRun.status !== 'completed') { + await new Promise((resolve) => setTimeout(resolve, 20000)); + continue; + } + + if (matchedRun.conclusion !== 'success') { + core.setFailed(`infra3 ansible deploy failed: ${matchedRun.html_url}`); + return; + } + + core.notice(`infra3 ansible deploy succeeded: ${matchedRun.html_url}`); + return; + } + + core.setFailed('Timed out waiting for infra3 ansible production deployment run to complete'); + + # --------------------------------------------------------------------------- + # 3. Manual rollback on EC2 infra repo via Ansible + # --------------------------------------------------------------------------- + ec2-rollback-manual: + name: "EC2 Rollback [manual] via Ansible" + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' + environment: ${{ inputs.environment }} + outputs: + rollback_tag: ${{ steps.dispatch_rollback.outputs.rollback_tag }} + steps: + - name: Validate rollback tag input + shell: bash + run: | + if [ -z "${{ inputs.rollback_tag }}" ]; then + echo "::error::rollback_tag is required when operation=rollback" + exit 1 + fi + + - name: Dispatch ansible rollback to infra3 repo + id: dispatch_rollback + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const env = '${{ inputs.environment }}'; + const rollbackTag = '${{ inputs.rollback_tag }}'; + const isProd = env === 'prod'; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: 'mypythonproject1-infra3', + workflow_id: 'ansible-ec2-deploy.yml', + ref: 'main', + inputs: { + environment: env, + operation: 'rollback', + image_tag: rollbackTag, + production_confirmation: isProd ? 'APPROVE_PROD_DEPLOY' : '', + }, + }); + console.log(`Dispatched ansible rollback on mypythonproject1-infra3 (${env}, tag=${rollbackTag})`); + core.setOutput('rollback_tag', rollbackTag); + + ec2-verify-rollback-manual: + name: "Verify EC2 Rollback [manual]" + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' + needs: [ec2-rollback-manual] + environment: ${{ inputs.environment }} + steps: + - name: Wait for infra3 ansible rollback result + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = 'mypythonproject1-infra3'; + const env = '${{ inputs.environment }}'; + const rollbackTag = '${{ inputs.rollback_tag }}'; + const envNeedle = `[${env}]`; + const opNeedle = 'rollback'; + const tagNeedle = `tag=${rollbackTag}`; + const timeoutMs = env === 'prod' ? 45 * 60 * 1000 : 30 * 60 * 1000; + const pollMs = 20000; + const deadline = Date.now() + timeoutMs; + + let matchedRun = null; + while (Date.now() < deadline) { + const resp = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'ansible-ec2-deploy.yml', + event: 'workflow_dispatch', + per_page: 20, + }); + + matchedRun = resp.data.workflow_runs.find((r) => { + const title = (r.display_title || '').toLowerCase(); + return title.includes(envNeedle.toLowerCase()) && title.includes(opNeedle) && title.includes(tagNeedle.toLowerCase()); + }); + + if (!matchedRun || matchedRun.status !== 'completed') { + await new Promise((resolve) => setTimeout(resolve, pollMs)); + continue; + } + + if (matchedRun.conclusion !== 'success') { + core.setFailed(`infra3 ansible rollback failed: ${matchedRun.html_url}`); + return; + } + + core.notice(`infra3 ansible rollback succeeded: ${matchedRun.html_url}`); + return; + } + + core.setFailed('Timed out waiting for infra3 ansible rollback run to complete'); + # --------------------------------------------------------------------------- # 2d. ECS rolling deploy (production) # --------------------------------------------------------------------------- @@ -348,7 +631,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 needs: [infra-production] - environment: production + environment: prod strategy: matrix: service: [backend, frontend] diff --git a/.github/workflows/cd-eks-gitops.yml b/.github/workflows/cd-eks-gitops.yml index 74b5c0b..97e5ca8 100644 --- a/.github/workflows/cd-eks-gitops.yml +++ b/.github/workflows/cd-eks-gitops.yml @@ -82,7 +82,7 @@ jobs: uses: ./.github/workflows/_build-and-push.yml with: git-ref: ${{ github.event.workflow_run.head_sha || github.sha }} - env-name: dev + env-name: ${{ inputs.environment }} tags: | type=raw,value=dev type=sha,prefix=dev-,format=short @@ -110,9 +110,10 @@ jobs: - uses: ./.github/actions/deploy-env with: - gitops-env: dev + gitops-env: default + target-environment: dev image-tag: ${{ format('dev-{0}', needs.build-dev.outputs.sha-short) }} - gitops-repo: ${{ github.repository_owner }}/mypythonproject1-gitops + gitops-repo: ${{ github.repository_owner }}/python-angular-project1-gitops eks-cluster-name: mypythonproject1-dev-eks namespace: mypythonproject1-dev aws-role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} @@ -151,7 +152,7 @@ jobs: uses: ./.github/workflows/_build-and-push.yml with: git-ref: ${{ github.event.workflow_run.head_sha || github.sha }} - env-name: staging + env-name: ${{ inputs.environment }} tags: | type=raw,value=staging type=sha,prefix=staging-,format=short @@ -179,9 +180,10 @@ jobs: - uses: ./.github/actions/deploy-env with: - gitops-env: staging + gitops-env: default + target-environment: staging image-tag: ${{ format('staging-{0}', needs.build-staging.outputs.sha-short) }} - gitops-repo: ${{ github.repository_owner }}/mypythonproject1-gitops + gitops-repo: ${{ github.repository_owner }}/gitgitops eks-cluster-name: mypythonproject1-staging-eks namespace: mypythonproject1-staging aws-role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} @@ -255,7 +257,7 @@ jobs: uses: ./.github/workflows/_build-and-push.yml with: git-ref: ${{ github.ref }} - env-name: prod + env-name: ${{ inputs.environment }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} @@ -286,9 +288,10 @@ jobs: - uses: ./.github/actions/deploy-env with: - gitops-env: prod + gitops-env: default + target-environment: prod image-tag: ${{ github.ref_name }} - gitops-repo: ${{ github.repository_owner }}/mypythonproject1-gitops + gitops-repo: ${{ github.repository_owner }}/python-angular-project1-gitops eks-cluster-name: mypythonproject1-prod-eks namespace: mypythonproject1-production aws-role-arn: ${{ secrets.AWS_ROLE_TO_ASSUME }} diff --git a/.gitignore b/.gitignore index ef670fd..13f674e 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,7 @@ temp/ poetry.lock.local package-lock.json.local -mypythonproject1.code-workspace \ No newline at end of file +mypythonproject1.code-workspace + +.gitlab-ci.yml +.gitlab/* \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index e4475b7..40b4a6d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -23,8 +23,8 @@ FROM python:${PYTHON_VERSION}-slim WORKDIR /app -# Create non-root user for security -RUN groupadd -r appuser && useradd -r -g appuser appuser +# Create non-root user for security (UID/GID 1000 to match K8s securityContext) +RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser # Install runtime dependencies only RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c21361c..37c4b5b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -155,6 +155,7 @@ def DATABASE_URL(self) -> str: f"postgresql+psycopg2://{self.DATABASE_USER}:" f"{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:" f"{self.DATABASE_PORT}/{self.DATABASE_NAME}" + f"?connect_timeout=5&sslmode=require" ) @property diff --git a/backend/start.sh b/backend/start.sh index b3080ba..1177c88 100644 --- a/backend/start.sh +++ b/backend/start.sh @@ -1,6 +1,20 @@ #!/bin/sh set -e +# Wait for database to be reachable before running migrations +MAX_RETRIES=${DB_WAIT_RETRIES:-10} +RETRY=0 +until python -c "import socket; s=socket.create_connection(('${DATABASE_HOST:-localhost}', ${DATABASE_PORT:-5432}), timeout=3); s.close()" 2>/dev/null; do + RETRY=$((RETRY + 1)) + if [ "$RETRY" -ge "$MAX_RETRIES" ]; then + echo "ERROR: Database not reachable after $MAX_RETRIES attempts. Exiting." + exit 1 + fi + echo "Waiting for database ($RETRY/$MAX_RETRIES)..." + sleep 3 +done +echo "Database is reachable." + echo "Running Alembic migrations..." alembic upgrade head echo "Migrations complete." diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bc56907..c6bb0ef 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,7 +22,8 @@ COPY src ./src RUN npm run build # Production stage -FROM nginx:${NGINX_TAG} +# Use unprivileged nginx: runs as UID 101, no chown needed, works with dropped capabilities +FROM nginxinc/nginx-unprivileged:${NGINX_TAG} # Copy nginx config with SPA fallback (try_files → index.html) COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/src/app/services/api.client.ts b/frontend/src/app/services/api.client.ts index 06fc10e..b131218 100644 --- a/frontend/src/app/services/api.client.ts +++ b/frontend/src/app/services/api.client.ts @@ -10,7 +10,7 @@ import { Observable } from 'rxjs'; providedIn: 'root' }) export class ApiClient { - private static readonly fallbackBaseUrl = 'http://localhost:8000'; + private static readonly fallbackBaseUrl = ''; private baseUrl: string; constructor(private http: HttpClient) { diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index c96d085..4bd1a43 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiUrl: 'https://api.example.com' + apiUrl: '' }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index da09db4..d946798 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -3,5 +3,5 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8000' + apiUrl: '' };