Files
cve-dashboard/.gitlab-ci.yml

334 lines
12 KiB
YAML

# =============================================================================
# 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
- key: backend-${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
policy: pull
script:
- cd frontend && (test -d node_modules || npm ci) && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
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