ci: rewrite pipeline for Docker executor on LXC 108

- Use node:18 image for install/lint/test/build stages
- SSH-based deploys from alpine container
- Base64-encoded SSH key from CI/CD variable
- Remove shell executor dependencies (.env file reads, local rsync)
- Concurrency 8 on new runner
This commit is contained in:
Jordan Ramos
2026-05-26 15:32:45 -06:00
parent 8d82245c86
commit 2a3b25526f

View File

@@ -1,44 +1,20 @@
# ============================================================================= # =============================================================================
# GitLab CI/CD Pipeline — STEAM Security Dashboard # GitLab CI/CD Pipeline — STEAM Security Dashboard
# ============================================================================= # =============================================================================
# # Executor: Docker (LXC 108 — 71.85.90.8)
# Pipeline stages: # Build/test jobs run in node:18 containers.
# 1. install — install dependencies for backend and frontend # Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
# 2. lint — run linters / static checks # and production (71.85.90.6) via SSH.
# 3. test — run backend (Jest) and frontend (react-scripts) tests
# 4. build — produce the production frontend bundle
# 5. deploy — deploy to staging (local) or production (SSH to 71.85.90.6)
# 6. verify — post-deploy health checks
#
# Environments:
# staging — dashboard-dev:3100 (auto-deploy on main/master)
# production — 71.85.90.6:3001 (manual trigger, requires staging verification)
#
# Executor: shell (runs on dashboard-dev using system Node.js)
# ============================================================================= # =============================================================================
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variables: variables:
PROD_HOST: "71.85.90.6" PROD_HOST: "71.85.90.6"
PROD_USER: "root" PROD_USER: "root"
PROD_DIR: "/home/cve-dashboard" PROD_DIR: "/home/cve-dashboard"
STAGING_HOST: "71.85.90.9"
STAGING_USER: "root"
STAGING_DIR: "/home/cve-dashboard-staging" STAGING_DIR: "/home/cve-dashboard-staging"
# ---------------------------------------------------------------------------
# Global cache — persists node_modules between pipeline runs
# ---------------------------------------------------------------------------
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- frontend/node_modules/
policy: pull
# ---------------------------------------------------------------------------
# Stages
# ---------------------------------------------------------------------------
stages: stages:
- install - install
- lint - lint
@@ -48,46 +24,37 @@ stages:
- verify - verify
# ============================================================================= # =============================================================================
# STAGE 1: Install dependencies # STAGE 1: Install
# ============================================================================= # =============================================================================
install-backend: install-backend:
stage: install stage: install
image: node:18
script: script:
- npm ci --prefer-offline - npm ci
cache: artifacts:
key: ${CI_COMMIT_REF_SLUG}
paths: paths:
- node_modules/ - node_modules/
policy: pull-push expire_in: 1 hour
install-frontend: install-frontend:
stage: install stage: install
image: node:18
script: script:
- cd frontend && npm ci --prefer-offline - cd frontend && npm ci
cache: artifacts:
key: ${CI_COMMIT_REF_SLUG}
paths: paths:
- frontend/node_modules/ - frontend/node_modules/
policy: pull-push expire_in: 1 hour
# ============================================================================= # =============================================================================
# STAGE 2: Lint / static analysis # STAGE 2: Lint
# ============================================================================= # =============================================================================
lint-frontend:
stage: lint
script:
# Allow up to 25 warnings (mostly unused vars from iterative development).
# Errors still block. Unused vars prefixed with _ are suppressed.
- cd frontend && npm ci --prefer-offline && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 25
needs:
- install-frontend
lint-backend: lint-backend:
stage: lint stage: lint
image: node:18
script: script:
- npm ci --prefer-offline
- node -c backend/server.js - node -c backend/server.js
- node -c backend/routes/*.js - node -c backend/routes/*.js
- node -c backend/helpers/*.js - node -c backend/helpers/*.js
@@ -95,27 +62,34 @@ lint-backend:
needs: needs:
- install-backend - install-backend
lint-frontend:
stage: lint
image: node:18
script:
- cd frontend && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 25
needs:
- install-frontend
# ============================================================================= # =============================================================================
# STAGE 3: Tests # STAGE 3: Test
# ============================================================================= # =============================================================================
test-backend: test-backend:
stage: test stage: test
image: node:18
variables:
DATABASE_URL: $DATABASE_URL
script: script:
- npm ci --prefer-offline - ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
# Source backend .env from the production install so DATABASE_URL is available
# for integration tests. Safe because the runner is on the same machine as the DB.
# Must be on same line as jest because each script line is a separate shell context.
- export $(grep -v '^#' /home/cve-dashboard/backend/.env | xargs) && ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
timeout: 5 minutes timeout: 5 minutes
needs: needs:
- install-backend - install-backend
test-frontend: test-frontend:
stage: test stage: test
image: node:18
script: script:
- npm ci --prefer-offline - cd frontend && CI=true npx react-scripts test --watchAll=false --ci
- cd frontend && npm ci --prefer-offline && CI=true npx react-scripts test --watchAll=false --ci
timeout: 5 minutes timeout: 5 minutes
needs: needs:
- install-frontend - install-frontend
@@ -126,8 +100,9 @@ test-frontend:
build-frontend: build-frontend:
stage: build stage: build
image: node:18
script: script:
- cd frontend && npm ci --prefer-offline && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build - cd frontend && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
artifacts: artifacts:
paths: paths:
- frontend/build/ - frontend/build/
@@ -137,26 +112,30 @@ build-frontend:
- lint-frontend - lint-frontend
# ============================================================================= # =============================================================================
# STAGE 5: Deploy # STAGE 5: Deploy (SSH from container)
# ============================================================================= # =============================================================================
# --------------------------------------------------------------------------- .deploy-base: &deploy-base
# Staging — auto-deploys on main/master to dashboard-dev:3100 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-staging:
<<: *deploy-base
stage: deploy stage: deploy
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
when: on_success when: on_success
environment: environment:
name: staging name: staging
url: http://localhost:3100 url: http://71.85.90.9:3100
script: script:
- echo "Deploying to staging (dashboard-dev:3100)..." - echo "Deploying to staging (${STAGING_HOST})..."
# Ensure staging directory exists - rsync -az --delete
- mkdir -p ${STAGING_DIR}
# Sync code (exclude .git, node_modules, uploads, logs)
- rsync -a --delete
--exclude='.git' --exclude='.git'
--exclude='node_modules' --exclude='node_modules'
--exclude='frontend/node_modules' --exclude='frontend/node_modules'
@@ -165,26 +144,16 @@ deploy-staging:
--exclude='*.log' --exclude='*.log'
--exclude='*.db' --exclude='*.db'
--exclude='.env' --exclude='.env'
${CI_PROJECT_DIR}/ ${STAGING_DIR}/ ./ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/
# Copy built frontend - rsync -az frontend/build/ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/frontend/build/
- cp -r ${CI_PROJECT_DIR}/frontend/build ${STAGING_DIR}/frontend/build - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR} && npm ci --prefer-offline"
# Install deps in staging - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/frontend && npm ci --prefer-offline"
- cd ${STAGING_DIR} && npm ci --prefer-offline - ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/backend && node migrations/run-all.js"
- cd ${STAGING_DIR}/frontend && npm ci --prefer-offline - ssh ${STAGING_USER}@${STAGING_HOST} "systemctl restart cve-backend-staging || systemctl start cve-backend-staging || true"
# Ensure staging .env exists
- |
if [ ! -f "${STAGING_DIR}/backend/.env" ]; then
cp ${CI_PROJECT_DIR}/backend/.env ${STAGING_DIR}/backend/.env
sed -i 's/^PORT=.*/PORT=3100/' ${STAGING_DIR}/backend/.env
grep -q "^PORT=" ${STAGING_DIR}/backend/.env || echo "PORT=3100" >> ${STAGING_DIR}/backend/.env
fi
# Run migrations
- cd ${STAGING_DIR}/backend && node migrations/run-all.js
# Restart staging service
- sudo systemctl restart cve-backend-staging || sudo systemctl start cve-backend-staging || true
- echo "Staging deploy complete." - echo "Staging deploy complete."
after_script: after_script:
- | - |
apk add --no-cache curl > /dev/null 2>&1
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u) ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
for ISSUE in $ISSUES; do for ISSUE in $ISSUES; do
curl --silent --request POST \ curl --silent --request POST \
@@ -197,10 +166,8 @@ deploy-staging:
- build-frontend - build-frontend
- test-backend - test-backend
# ---------------------------------------------------------------------------
# Production — manual trigger, SSH to 71.85.90.6
# ---------------------------------------------------------------------------
deploy-production: deploy-production:
<<: *deploy-base
stage: deploy stage: deploy
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
@@ -210,10 +177,8 @@ deploy-production:
url: http://71.85.90.6:3001 url: http://71.85.90.6:3001
script: script:
- echo "Deploying to production (${PROD_HOST})..." - echo "Deploying to production (${PROD_HOST})..."
# Record current commit on prod for rollback
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit - 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)" - echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
# Sync code to production (exclude local-only files)
- rsync -az --delete - rsync -az --delete
--exclude='.git' --exclude='.git'
--exclude='node_modules' --exclude='node_modules'
@@ -224,20 +189,17 @@ deploy-production:
--exclude='*.db' --exclude='*.db'
--exclude='.env' --exclude='.env'
--exclude='.compliance-staging' --exclude='.compliance-staging'
${CI_PROJECT_DIR}/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/ ./ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
# Copy built frontend - rsync -az frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
- rsync -az ${CI_PROJECT_DIR}/frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
# Install deps on production
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline" - 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}/frontend && npm ci --prefer-offline"
# Run migrations
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js" - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js"
# Restart services — install systemd unit if not present - ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service || true"
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service" || scp ${CI_PROJECT_DIR}/deploy/cve-backend-production.service ${PROD_USER}@${PROD_HOST}:/etc/systemd/system/cve-backend.service
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend" - ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
- echo "Production deploy complete." - echo "Production deploy complete."
after_script: after_script:
- | - |
apk add --no-cache curl > /dev/null 2>&1
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u) ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
for ISSUE in $ISSUES; do for ISSUE in $ISSUES; do
curl --silent --request POST \ curl --silent --request POST \
@@ -251,23 +213,22 @@ deploy-production:
- test-backend - test-backend
# ============================================================================= # =============================================================================
# STAGE 6: Post-deploy verification # STAGE 6: Verify
# ============================================================================= # =============================================================================
# ---------------------------------------------------------------------------
# Staging health check
# ---------------------------------------------------------------------------
verify-staging: verify-staging:
stage: verify stage: verify
image: alpine:latest
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
when: on_success when: on_success
script: script:
- apk add --no-cache curl
- echo "Verifying staging..." - echo "Verifying staging..."
- sleep 3 - sleep 3
- | - |
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3100/api/health 2>/dev/null || echo "000") STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${STAGING_HOST}:3100/api/health 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then if [ "$STATUS" = "200" ]; then
echo "Staging health check passed (attempt $i)" echo "Staging health check passed (attempt $i)"
break break
@@ -279,37 +240,28 @@ verify-staging:
echo "FAILED: Staging health check failed after 5 attempts" echo "FAILED: Staging health check failed after 5 attempts"
exit 1 exit 1
fi fi
# --- Post-deploy smoke tests (non-blocking for now) ---
# These can be made blocking once stable by changing WARN to FAIL and adding exit 1.
- | - |
# Smoke test: compliance items endpoint returns valid JSON 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=$(curl -s -o /tmp/comp-response -w "%{http_code}" http://localhost:3100/api/compliance/items?page=1&limit=1 2>/dev/null || echo "000") [ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
if [ "$COMP_STATUS" != "200" ]; then
echo "WARN: Compliance items endpoint returned $COMP_STATUS (non-blocking)"
fi
- | - |
# Smoke test: VCL stats endpoint returns valid JSON VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/vcl/stats" || echo "000")
VCL_STATUS=$(curl -s -o /tmp/vcl-response -w "%{http_code}" http://localhost:3100/api/compliance/vcl/stats 2>/dev/null || echo "000") [ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
if [ "$VCL_STATUS" != "200" ]; then
echo "WARN: VCL stats endpoint returned $VCL_STATUS (non-blocking)"
fi
- |
# Smoke test: verify migration ran (compliance_item_history has metric_id column)
SCHEMA_CHECK=$(curl -s http://localhost:3100/api/health 2>/dev/null | grep -c '"status":"ok"' || echo "0")
echo "Schema health: $SCHEMA_CHECK"
- echo "Staging verification passed." - echo "Staging verification passed."
needs: needs:
- deploy-staging - deploy-staging
# ---------------------------------------------------------------------------
# Production health check — rolls back on failure
# ---------------------------------------------------------------------------
verify-production: verify-production:
stage: verify stage: verify
image: alpine:latest
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
when: on_success when: on_success
script: 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..." - echo "Verifying production..."
- sleep 3 - sleep 3
- | - |
@@ -327,7 +279,6 @@ verify-production:
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "") PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
echo "Rolling back to $PREV_COMMIT..." echo "Rolling back to $PREV_COMMIT..."
# Re-sync the previous version
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} && 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} "cd ${PROD_DIR} && npm ci --prefer-offline"
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend" ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
@@ -337,24 +288,12 @@ verify-production:
fi fi
exit 1 exit 1
fi fi
# --- Post-deploy smoke tests (non-blocking for now) ---
# These can be made blocking once stable by changing WARN to FAIL and adding exit 1.
- | - |
# Smoke test: compliance items endpoint returns valid JSON 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=$(curl -s -o /tmp/comp-response -w "%{http_code}" http://${PROD_HOST}:3001/api/compliance/items?page=1&limit=1 2>/dev/null || echo "000") [ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
if [ "$COMP_STATUS" != "200" ]; then
echo "WARN: Compliance items endpoint returned $COMP_STATUS (non-blocking)"
fi
- | - |
# Smoke test: VCL stats endpoint returns valid JSON VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/vcl/stats" || echo "000")
VCL_STATUS=$(curl -s -o /tmp/vcl-response -w "%{http_code}" http://${PROD_HOST}:3001/api/compliance/vcl/stats 2>/dev/null || echo "000") [ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
if [ "$VCL_STATUS" != "200" ]; then
echo "WARN: VCL stats endpoint returned $VCL_STATUS (non-blocking)"
fi
- |
# Smoke test: verify migration ran (compliance_item_history has metric_id column)
SCHEMA_CHECK=$(curl -s http://${PROD_HOST}:3001/api/health 2>/dev/null | grep -c '"status":"ok"' || echo "0")
echo "Schema health: $SCHEMA_CHECK"
- echo "Production verification passed." - echo "Production verification passed."
needs: needs:
- deploy-production - deploy-production