# ============================================================================= # GitLab CI/CD Pipeline — STEAM Security Dashboard # ============================================================================= # Executor: Docker (LXC 108 — 71.85.90.8) # Build/test jobs run in node:18 containers. # Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9) # and production (71.85.90.6) via SSH. # ============================================================================= variables: PROD_HOST: "71.85.90.6" PROD_USER: "root" PROD_DIR: "/home/cve-dashboard" STAGING_HOST: "71.85.90.9" STAGING_USER: "root" STAGING_DIR: "/home/cve-dashboard-staging" stages: - install - lint - test - build - deploy - verify # ============================================================================= # STAGE 1: Install # ============================================================================= install-backend: stage: install image: node:18 script: - npm ci cache: key: backend-${CI_COMMIT_REF_SLUG} paths: - node_modules/ policy: push install-frontend: stage: install image: node:18 script: - cd frontend && npm ci cache: key: frontend-${CI_COMMIT_REF_SLUG} paths: - frontend/node_modules/ policy: push # ============================================================================= # STAGE 2: Lint # ============================================================================= lint-backend: stage: lint image: node:18 cache: key: backend-${CI_COMMIT_REF_SLUG} paths: - node_modules/ policy: pull script: - test -d node_modules || npm ci - node -c backend/server.js - node -c backend/routes/*.js - node -c backend/helpers/*.js - node -c backend/middleware/*.js needs: - install-backend lint-frontend: stage: lint image: node:18 cache: key: frontend-${CI_COMMIT_REF_SLUG} paths: - frontend/node_modules/ policy: pull script: - cd frontend && (test -d node_modules || npm ci) && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 25 needs: - install-frontend # ============================================================================= # STAGE 3: Test # ============================================================================= test-backend: stage: test image: node:18 variables: DATABASE_URL: $DATABASE_URL cache: key: backend-${CI_COMMIT_REF_SLUG} paths: - node_modules/ policy: pull script: - test -d node_modules || npm ci - ./node_modules/.bin/jest --ci --forceExit backend/__tests__/ timeout: 5 minutes needs: - install-backend test-frontend: stage: test image: node:18 cache: key: frontend-${CI_COMMIT_REF_SLUG} paths: - frontend/node_modules/ policy: pull script: - cd frontend && (test -d node_modules || npm ci) && CI=true npx react-scripts test --watchAll=false --ci --testPathIgnorePatterns='atlasMetricsAggregation.property.test' timeout: 5 minutes needs: - install-frontend # ============================================================================= # STAGE 4: Build # ============================================================================= build-frontend: stage: build image: node:18 cache: key: frontend-${CI_COMMIT_REF_SLUG} paths: - frontend/node_modules/ policy: pull script: - cd frontend && (test -d node_modules || npm ci) && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build artifacts: paths: - frontend/build/ expire_in: 7 days needs: - test-frontend - lint-frontend # ============================================================================= # STAGE 5: Deploy (SSH from container) # ============================================================================= .deploy-base: &deploy-base image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config deploy-staging: <<: *deploy-base stage: deploy rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" when: on_success environment: name: staging url: http://71.85.90.9:3100 script: - echo "Deploying to staging (${STAGING_HOST})..." - rsync -az --delete --exclude='.git' --exclude='node_modules' --exclude='frontend/node_modules' --exclude='frontend/build' --exclude='backend/uploads' --exclude='*.log' --exclude='*.db' --exclude='.env' ./ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/ - rsync -az frontend/build/ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/frontend/build/ - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR} && npm ci --prefer-offline" - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/frontend && npm ci --prefer-offline" - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/backend && node migrations/run-all.js" - ssh ${STAGING_USER}@${STAGING_HOST} "systemctl restart cve-backend-staging || systemctl start cve-backend-staging || true" - echo "Staging deploy complete." after_script: - | apk add --no-cache curl > /dev/null 2>&1 ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u) for ISSUE in $ISSUES; do curl --silent --request POST \ --header "PRIVATE-TOKEN: ${GITLAB_PAT}" \ "${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \ --data-urlencode "body=✅ Deployed to **staging** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \ > /dev/null 2>&1 || true done needs: - build-frontend - test-backend deploy-production: <<: *deploy-base stage: deploy rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" when: manual environment: name: production url: http://71.85.90.6:3001 script: - echo "Deploying to production (${PROD_HOST})..." - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit - echo "Previous production commit:$(cat /tmp/prod-prev-commit)" - rsync -az --delete --exclude='.git' --exclude='node_modules' --exclude='frontend/node_modules' --exclude='frontend/build' --exclude='backend/uploads' --exclude='*.log' --exclude='*.db' --exclude='.env' --exclude='.compliance-staging' ./ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/ - rsync -az frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/ - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline" - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline" - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js" - ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service || true" - ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend" - echo "Production deploy complete." after_script: - | apk add --no-cache curl > /dev/null 2>&1 ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u) for ISSUE in $ISSUES; do curl --silent --request POST \ --header "PRIVATE-TOKEN: ${GITLAB_PAT}" \ "${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \ --data-urlencode "body=🚀 Deployed to **production** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \ > /dev/null 2>&1 || true done needs: - build-frontend - test-backend # ============================================================================= # STAGE 6: Verify # ============================================================================= verify-staging: stage: verify image: alpine:latest rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" when: on_success script: - apk add --no-cache curl - echo "Verifying staging..." - sleep 3 - | for i in 1 2 3 4 5; do STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${STAGING_HOST}:3100/api/health 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo "Staging health check passed (attempt $i)" break fi echo "Staging not ready (status: $STATUS), retrying... (attempt $i/5)" sleep 3 done if [ "$STATUS" != "200" ]; then echo "FAILED: Staging health check failed after 5 attempts" exit 1 fi - | COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/items?page=1&limit=1" || echo "000") [ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true - | VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/vcl/stats" || echo "000") [ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true - echo "Staging verification passed." needs: - deploy-staging verify-production: stage: verify image: alpine:latest rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" when: on_success script: - apk add --no-cache curl openssh-client - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config - echo "Verifying production..." - sleep 3 - | for i in 1 2 3 4 5 6 7 8 9 10; do STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${PROD_HOST}:3001/api/health 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo "Production health check passed (attempt $i)" break fi echo "Production not ready (status: $STATUS), retrying... (attempt $i/10)" sleep 3 done if [ "$STATUS" != "200" ]; then echo "FAILED: Production health check failed — initiating rollback" PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "") if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then echo "Rolling back to $PREV_COMMIT..." ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git checkout ${PREV_COMMIT} --force 2>/dev/null" || true ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline" ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend" echo "Rollback complete. Verify manually." else echo "No previous commit recorded — manual intervention required." fi exit 1 fi - | COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/items?page=1&limit=1" || echo "000") [ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true - | VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/vcl/stats" || echo "000") [ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true - echo "Production verification passed." needs: - deploy-production allow_failure: false