diff --git a/Jenkinsfile.prod b/Jenkinsfile.prod new file mode 100644 index 0000000..86deeaf --- /dev/null +++ b/Jenkinsfile.prod @@ -0,0 +1,225 @@ +// Single source of truth for env injection. +// Adding/removing a variable means editing one `keys` list — nothing else. +// `path` is the Vault KV path; `file` is the output path relative to ${WORKSPACE}. +def ENV_LAYOUT = [ + [path: 'ggh-prod/root', file: '.env', keys: [ + 'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_USER', + ]], + [path: 'ggh-prod/backend', file: 'backend/.env', keys: [ + 'BYTESCALE_SECRET_API_KEY', + 'CORS_ALLOWED_ORIGINS', + 'DATABASE_URL', + 'EMAIL_FROM', + 'JWT_SECRET', + 'PORT', + 'POSTMARK_API_KEY', + ]], + [path: 'ggh-prod/dashboard-frontend', file: 'frontend/.env', keys: [ + 'VITE_API_URL', + ]], +] + +pipeline { + agent { + label 'jagent06' + } + + environment { + REMOTE_HOST = "root@170.187.237.83" + REMOTE_DEPLOY_DIR = "/root/gg-backend" + SSH_CREDENTIALS_ID = "ssh_id_ed25519" + COMPOSE_FILE = "docker-compose.prod.yml" + } + + stages { + stage('Checkout Code') { + steps { + git credentialsId: 'SYS_BOT_PAT', url: 'https://gt.mgsigma.net/GGH/gg-backend.git', branch: 'main' + } + } + + stage('Inject .env from Vault') { + steps { + script { + def vaultSecrets = ENV_LAYOUT.collect { layer -> + [ + path: layer.path, + engineVersion: 2, + secretValues: layer.keys.collect { k -> + [vaultKey: k, envVar: k] + } + ] + } + + def writtenFiles = ENV_LAYOUT.collect { it.file }.join(', ') + + withVault( + configuration: [ + vaultUrl: 'https://vault.mgsigma.net', + vaultCredentialId: 'vault-cicd-01' + ], + vaultSecrets: vaultSecrets + ) { + // Single-quoted sh + withEnv keeps secret names out of the + // Groovy GString. Values are read at shell runtime via + // `printenv`, so Jenkins's static analyzer has nothing to flag. + ENV_LAYOUT.each { layer -> + withEnv([ + "ENV_FILE=${layer.file}", + "ENV_KEYS=${layer.keys.join(' ')}" + ]) { + sh ''' + set +x + umask 077 + mkdir -p "$(dirname "$WORKSPACE/$ENV_FILE")" + : > "$WORKSPACE/$ENV_FILE" + for k in $ENV_KEYS; do + printf '%s=%s\n' "$k" "$(printenv "$k")" >> "$WORKSPACE/$ENV_FILE" + done + ''' + } + } + echo "[INFO] env files written: ${writtenFiles}" + } + } + } + } + + stage('Sync sources to prod VPS') { + steps { + sshagent (credentials: ["${env.SSH_CREDENTIALS_ID}"]) { + sh """ + ssh -o StrictHostKeyChecking=no \$REMOTE_HOST 'mkdir -p \$REMOTE_DEPLOY_DIR' + rsync -az --delete \\ + --exclude='.git/' \\ + --exclude='node_modules/' \\ + --exclude='backend/node_modules/' \\ + --exclude='frontend/node_modules/' \\ + --exclude='frontend/dist/' \\ + -e "ssh -o StrictHostKeyChecking=no" \\ + ./ \$REMOTE_HOST:\$REMOTE_DEPLOY_DIR/ + """ + } + } + } + + stage('Deploy on prod VPS') { + steps { + sshagent (credentials: ["${env.SSH_CREDENTIALS_ID}"]) { + sh """ + ssh -o StrictHostKeyChecking=no \$REMOTE_HOST ' + set -e + cd \$REMOTE_DEPLOY_DIR + + if [ -n "\$(docker compose -f $COMPOSE_FILE ps -q 2>/dev/null)" ]; then + echo "[INFO] Stopping existing containers..." + docker compose -f $COMPOSE_FILE down --remove-orphans + else + echo "[INFO] No running containers — skipping stop." + fi + + docker image prune -f >/dev/null 2>&1 || true + + echo "[INFO] Building and starting services..." + docker compose -f $COMPOSE_FILE up -d --build --remove-orphans + ' + """ + } + } + } + } + + post { + success { + script { + def causes = currentBuild.getBuildCauses() + def triggeredBy = causes ? causes[0].shortDescription : "Unknown" + def isManual = triggeredBy.contains('Started by user') + + def commitHash = sh(script: "git rev-parse --short HEAD", returnStatus: true) == 0 ? + sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() : "N/A" + def commitAuthor = sh(script: "git log -1 --pretty=format:%an", returnStatus: true) == 0 ? + sh(script: "git log -1 --pretty=format:%an", returnStdout: true).trim() : "N/A" + def commitMessage = sh(script: "git log -1 --pretty=format:%s", returnStatus: true) == 0 ? + sh(script: "git log -1 --pretty=format:%s", returnStdout: true).trim() : "No commit message found" + + def envInfo = """ + + """ + emailext ( + subject: "[PRODUCTION] SUCCESS: ${env.JOB_NAME} #${env.BUILD_NUMBER}", + body: """ +
+

Production Deployment Successful

+ ${envInfo} +

Commit Info:

+ +

Review the output here:

+

+ View Build Log +

+
+

Production deployment via Jenkins. Contact DevOps for any issues.

+
+ """, + mimeType: 'text/html', + to: 'admin@msigmagokulam.com, ashir@mgsigma.net, ajiahamed@msigmagokulam.com, arjunsthampi@mgsigma.net' + ) + } + } + + failure { + script { + def causes = currentBuild.getBuildCauses() + def triggeredBy = causes ? causes[0].shortDescription : "Unknown" + def isManual = triggeredBy.contains('Started by user') + + def commitHash = sh(script: "git rev-parse --short HEAD", returnStatus: true) == 0 ? + sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() : "N/A" + def commitAuthor = sh(script: "git log -1 --pretty=format:%an", returnStatus: true) == 0 ? + sh(script: "git log -1 --pretty=format:%an", returnStdout: true).trim() : "N/A" + def commitMessage = sh(script: "git log -1 --pretty=format:%s", returnStatus: true) == 0 ? + sh(script: "git log -1 --pretty=format:%s", returnStdout: true).trim() : "No commit message found" + + def envInfo = """ + + """ + emailext ( + subject: "[PRODUCTION] FAILURE: ${env.JOB_NAME} #${env.BUILD_NUMBER}", + body: """ +
+

Production Deployment Failed

+ ${envInfo} +

Commit Info:

+ +

Review error logs here:

+

+ View Build Log +

+
+

Production deployment via Jenkins. Contact DevOps for any issues.

+
+ """, + mimeType: 'text/html', + to: 'admin@msigmagokulam.com, ashir@mgsigma.net, ajiahamed@msigmagokulam.com, arjunsthampi@mgsigma.net' + ) + } + } + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..309159c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,41 @@ +services: + backend: + build: + context: . + dockerfile: docker/dev/Dockerfile.main + ports: + - "127.0.0.1:5008:5008" + env_file: + - ./backend/.env + depends_on: + db: + condition: service_healthy + restart: always + + frontend: + build: + context: . + dockerfile: docker/dev/Dockerfile.frontend + ports: + - "127.0.0.1:3008:3000" + env_file: + - ./frontend/.env + restart: always + + db: + image: postgres:16-alpine + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 + restart: always + +volumes: + postgres_data: