NutriTrack SaaS — Jenkins CI/CD Pipeline

Monorepo · 3 servicios (api, web, worker) · GKE + Helm · Detección de cambios automática

Jenkins 2.440 Kubernetes Agents Declarative Multibranch

Flujo del pipeline

📦 Install
~45s
paralelo
🔍 Lint
🧪 Tests
🔒 Audit
~2m30s
🐳 Build & Push
~3m
🚀 Deploy Staging
~1m30s · auto
Aprobación
manual · platform
🌐 Deploy Prod
~2m · rollback auto
Jenkinsfile
// NutriTrack — Monorepo CI/CD Pipeline
// Servicios: api (NestJS), web (React/Vite), worker (Node cron)
// Infra: GKE + Helm + GCR · Kubernetes ephemeral agents

@Library('nutritrack-pipeline-lib') _

pipeline {
  agent {
    kubernetes {
      yaml '''
apiVersion: v1
kind: Pod
spec:
  serviceAccountName: jenkins-agent
  containers:
    - name: node
      image: node:20-alpine
      command: ["sleep", "99d"]
      resources:
        requests: { cpu: "500m", memory: "1Gi" }
        limits:   { cpu: "2",    memory: "2Gi" }
    - name: docker
      image: gcr.io/cloud-builders/docker:24
      command: ["sleep", "99d"]
      securityContext: { privileged: true }
    - name: helm
      image: alpine/helm:3.14
      command: ["sleep", "99d"]
'''
      defaultContainer 'node'
    }
  }

  options {
    timeout(time: 45, unit: 'MINUTES')
    disableConcurrentBuilds(abortPrevious: true)
    buildDiscarder(logRotator(numToKeepStr: '30'))
    timestamps()
  }

  environment {
    REGISTRY    = 'gcr.io/nutritrack-prod'
    GIT_SHORT   = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
    SERVICES    = 'api web worker'
    HELM_TIMEOUT = '10m'
  }

  stages {

    // ─── DETECT CHANGES ──────────────────────────────────────
    stage('Detect Changes') {
      steps {
        script {
          env.CHANGED_API    = sh(returnStdout: true,
            script: 'git diff --name-only HEAD~1 | grep -c "^services/api" || echo 0').trim()
          env.CHANGED_WEB    = sh(returnStdout: true,
            script: 'git diff --name-only HEAD~1 | grep -c "^services/web" || echo 0').trim()
          env.CHANGED_WORKER = sh(returnStdout: true,
            script: 'git diff --name-only HEAD~1 | grep -c "^services/worker" || echo 0').trim()
          // shared-lib change triggers rebuild of all dependents
          def sharedChanged = sh(returnStdout: true,
            script: 'git diff --name-only HEAD~1 | grep -c "^libs/shared" || echo 0').trim()
          if (sharedChanged != '0') {
            env.CHANGED_API = env.CHANGED_WEB = env.CHANGED_WORKER = '1'
            echo "⚠ shared-lib modificado → rebuild completo"
          }
        }
      }
    }

    // ─── INSTALL ─────────────────────────────────────────────
    stage('Install') {
      steps { sh 'npm ci --workspaces' }
    }

    // ─── LINT & TEST & SECURITY (paralelo) ───────────────────
    stage('Quality Gate') {
      parallel {
        stage('Lint') {
          steps { sh 'npm run lint --workspaces' }
        }
        stage('Unit Tests') {
          steps {
            sh 'npm test --workspaces -- --ci --coverage --reporters=junit'
          }
          post {
            always {
              junit '**/reports/junit.xml'
              publishHTML(target: [reportDir: 'coverage', reportFiles: 'index.html',
                reportName: 'Coverage Report'])
            }
          }
        }
        stage('Security Audit') {
          steps {
            sh 'npm audit --audit-level=high --workspaces'
            sh 'npx snyk test --severity-threshold=high || true'
          }
        }
      }
    }

    // ─── BUILD & PUSH DOCKER (solo servicios cambiados) ──────
    stage('Build Images') {
      parallel {
        stage('Build API') {
          when { expression { return env.CHANGED_API != '0' } }
          steps {
            container('docker') {
              buildDockerImage(service: 'api', tag: env.GIT_SHORT,
                               registry: env.REGISTRY)
            }
          }
        }
        stage('Build Web') {
          when { expression { return env.CHANGED_WEB != '0' } }
          steps {
            container('docker') {
              buildDockerImage(service: 'web', tag: env.GIT_SHORT,
                               registry: env.REGISTRY)
            }
          }
        }
        stage('Build Worker') {
          when { expression { return env.CHANGED_WORKER != '0' } }
          steps {
            container('docker') {
              buildDockerImage(service: 'worker', tag: env.GIT_SHORT,
                               registry: env.REGISTRY)
            }
          }
        }
      }
    }

    // ─── DEPLOY STAGING (automático en main) ─────────────────
    stage('Deploy Staging') {
      when {
        anyOf {
          branch 'main'
          branch pattern: 'release/.*', comparator: 'REGEXP'
        }
      }
      steps {
        script {
          withCredentials([file(credentialsId: 'kubeconfig-staging',
                                variable: 'KUBECONFIG')]) {
            container('helm') {
              ['api', 'web', 'worker'].each { svc ->
                if (env["CHANGED_${svc.toUpperCase()}"] != '0') {
                  deployService(service: svc, env: 'staging',
                                tag: env.GIT_SHORT, wait: true)
                }
              }
            }
          }
        }
      }
    }

    // ─── APROBACIÓN MANUAL ───────────────────────────────────
    stage('Aprobar Producción') {
      when { branch 'main' }
      input {
        message '¿Desplegar en producción?'
        ok      'Desplegar'
        submitter 'platform-team,admin'
        parameters {
          booleanParam(defaultValue: true, name: 'RUN_SMOKE',
            description: 'Ejecutar smoke tests post-deploy')
        }
      }
      steps { echo 'Producción aprobada. Iniciando despliegue...' }
    }

    // ─── DEPLOY PRODUCCIÓN ───────────────────────────────────
    stage('Deploy Producción') {
      when { branch 'main' }
      steps {
        script {
          withCredentials([file(credentialsId: 'kubeconfig-prod',
                                variable: 'KUBECONFIG')]) {
            container('helm') {
              ['api', 'web', 'worker'].each { svc ->
                if (env["CHANGED_${svc.toUpperCase()}"] != '0') {
                  deployService(service: svc, env: 'production',
                                tag: env.GIT_SHORT, wait: true,
                                rollbackOnFail: true)
                }
              }
            }
          }
        }
      }
    }
  }

  post {
    success {
      slackSend(channel: '#dev-ops', color: 'good',
        message: "✅ *NutriTrack* `${env.GIT_SHORT}` desplegado en producción · <${env.BUILD_URL}|Ver build>")
    }
    failure {
      slackSend(channel: '#dev-ops', color: 'danger',
        message: "🔴 *NutriTrack* build fallido en `${env.BRANCH_NAME}` · <${env.BUILD_URL}|Logs>")
    }
    always { cleanWs() }
  }
}
vars/deployService.groovy
// Shared Library — deployService
// Uso: deployService(service: 'api', env: 'staging',
//                    tag: '3a7f2c1', rollbackOnFail: true)

def call(Map cfg) {
  def svc        = cfg.service
  def targetEnv  = cfg.env
  def tag        = cfg.tag ?: env.GIT_SHORT
  def ns         = targetEnv
  def releaseName = "nutritrack-${svc}"
  def chartPath  = "./charts/${svc}"

  echo "→ Deploying ${svc}:${tag} to ${targetEnv}"

  try {
    sh """
      helm upgrade --install ${releaseName} ${chartPath} \\
        --namespace ${ns} \\
        --create-namespace \\
        --set image.repository=gcr.io/nutritrack-prod/${svc} \\
        --set image.tag=${tag} \\
        --set env=${targetEnv} \\
        --wait \\
        --timeout ${env.HELM_TIMEOUT ?: '10m'} \\
        --atomic
    """
    echo "✓ ${svc} desplegado correctamente en ${targetEnv}"
  } catch (Exception e) {
    echo "✗ Deploy falló: ${e.message}"
    if (cfg.rollbackOnFail) {
      echo "↩ Ejecutando rollback automático..."
      sh "helm rollback ${releaseName} 0 -n ${ns} --wait"
    }
    throw e
  }
}
vars/buildDockerImage.groovy
// Shared Library — buildDockerImage
// Uso: buildDockerImage(service: 'api', tag: '3a7f2c1',
//                       registry: 'gcr.io/nutritrack-prod')

def call(Map cfg) {
  def svc      = cfg.service
  def tag      = cfg.tag
  def registry = cfg.registry
  def image    = "${registry}/${svc}"
  def fullTag  = "${image}:${tag}"
  def context  = "./services/${svc}"

  sh "docker build -t ${fullTag} ${context}"

  // Workload Identity para GCR — sin credenciales explícitas
  sh "gcloud auth configure-docker gcr.io --quiet"
  sh "docker push ${fullTag}"

  if (env.BRANCH_NAME == 'main') {
    sh "docker tag ${fullTag} ${image}:latest"
    sh "docker push ${image}:latest"
  }

  echo "✓ Imagen publicada: ${fullTag}"
  return fullTag
}

🔍 Detección de cambios (Paso 1)

Servicio Estado Acción
services/api ● modificado Build + Deploy
libs/shared ● modificado Fuerza rebuild total
services/web — sin cambios Skip build (↑ shared)
services/worker — sin cambios Skip build (↑ shared)

🔐 Gestión de credenciales (withCredentials)

file kubeconfig-staging Cluster GKE staging
file kubeconfig-prod Cluster GKE production
workload-id GCR auto-auth Push a Google Container Registry
string slack-token Notificaciones #dev-ops

📊 Métricas de pipeline (estimadas)

Duración total (full build) ~9 min
Duración con skip (sin cambios) ~3 min 20s
Parallelismo (Quality Gate) 3 threads
Agents Kubernetes (efímeros) Pod per build
Builds retenidos últimos 30
Timeout global 45 min

💬 Notificación Slack — Deploy exitoso

🤖 Jenkins CI APP
NutriTrack 3a7f2c1 desplegado en producción
Servicios actualizados: api web worker
Rama: main · Build: #247
Duración: 8m 43s · Entorno: production (GKE)
hoy a las 14:32

🛠 Stack y plugins requeridos

Jenkins 2.440+ Pipeline plugin Kubernetes plugin Docker Pipeline Helm 3.14 GKE (GCP) GCR Shared Libraries JUnit plugin HTML Publisher Slack Notification GitHub Branch Source Snyk Security Credentials Binding Workspace Cleanup