Introducción

Jenkins es uno de los servidores de automatización más populares del mundo open-source. A diferencia de soluciones SaaS como GitHub Actions, Jenkins se instala y gestiona en tu propia infraestructura, lo que lo convierte en la opción preferida para empresas con requisitos estrictos de seguridad, compliance o que necesitan integrar con sistemas internos.

En este módulo aprenderás a configurar un pipeline Jenkins completo para Terraform, cubriendo desde la instalación de dependencias hasta la gestión segura de credenciales y las aprobaciones manuales antes de aplicar cambios en producción.


Introducción a Jenkins para IaC

Jenkins organiza el trabajo en Pipelines, que son secuencias de etapas (stages) que se ejecutan en orden. Para Terraform, un pipeline típico sigue este flujo:

[Checkout] → [Init] → [Format] → [Validate] → [Plan] → [Approval] → [Apply]

Jenkins puede ejecutar pipelines de dos formas:

  • Freestyle Projects: configuración gráfica, menos flexible
  • Pipeline Projects: código en un archivo Jenkinsfile, versionado en Git (recomendado)

El Jenkinsfile puede ser de dos tipos:

  • Scripted Pipeline: usa sintaxis Groovy libre, más flexible pero complejo
  • Declarative Pipeline: sintaxis estructurada, más legible y recomendada para la mayoría de casos

En este módulo usaremos Declarative Pipeline, que es el estándar moderno.


Instalación de Terraform en agentes Jenkins

Los agentes Jenkins (también llamados "workers" o "nodes") necesitan tener Terraform instalado. Hay varias estrategias:

Opción 1: Instalar Terraform directamente en el agente

# En el agente Jenkins (Ubuntu/Debian)
# Añadir el repositorio oficial de HashiCorp
wget -O- https://apt.releases.hashicorp.com/gpg | \
  gpg --dearmor | \
  sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null

echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update
sudo apt install terraform=1.9.0-1

# Verificar la instalación
terraform version
# Output: Terraform v1.9.0

Opción 2: Instalar Terraform en el Jenkinsfile (automático)

// En el Jenkinsfile, instalar Terraform si no está disponible
stage('Setup') {
    steps {
        sh '''
            # Verificar si Terraform está instalado
            if ! command -v terraform &> /dev/null; then
                echo "Instalando Terraform..."
                wget -q https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
                unzip -q terraform_1.9.0_linux_amd64.zip
                sudo mv terraform /usr/local/bin/
                echo "Terraform instalado:"
                terraform version
            else
                echo "Terraform ya está instalado:"
                terraform version
            fi
        '''
    }
}

Opción 3: Usar contenedores Docker como agentes (recomendado)

// Jenkinsfile con agente Docker
pipeline {
    agent {
        docker {
            // Imagen oficial de HashiCorp con Terraform preinstalado
            image 'hashicorp/terraform:1.9.0'
            args '-u root --entrypoint='
        }
    }
    // ...resto del pipeline
}

Opción 4: Usar tfenv para gestionar versiones

tfenv es un gestor de versiones de Terraform, útil cuando tienes múltiples proyectos con versiones diferentes.

# Instalar tfenv en el agente
git clone --depth=1 https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

# Instalar y usar una versión específica
tfenv install 1.9.0
tfenv use 1.9.0

# Verificar
terraform version

Configurar credenciales de nube en Jenkins

Jenkins tiene un sistema de credenciales centralizado que permite almacenar y usar secretos de forma segura sin exponerlos en los logs o en el código.

Configurar credenciales de AWS

  1. Ve a Jenkins > Manage Jenkins > Manage Credentials
  2. Selecciona el dominio Global
  3. Haz clic en Add Credentials
  4. Selecciona el tipo:
    • Para AWS Access Key: Secret text (una credencial por cada clave)
    • O bien: AWS Credentials (plugin de AWS, gestiona ambas claves juntas)

Para usar las credenciales de AWS en el Jenkinsfile:

// Método 1: Usando el plugin de AWS Credentials
pipeline {
    environment {
        // Esto carga AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY automáticamente
        AWS_CREDENTIALS = credentials('aws-produccion-credentials')
    }
    stages {
        stage('Terraform Plan') {
            steps {
                withCredentials([[
                    $class: 'AmazonWebServicesCredentialsBinding',
                    credentialsId: 'aws-produccion-credentials',
                    accessKeyVariable: 'AWS_ACCESS_KEY_ID',
                    secretKeyVariable: 'AWS_SECRET_ACCESS_KEY'
                ]]) {
                    sh 'terraform plan'
                }
            }
        }
    }
}

// Método 2: Usando Secret Text para cada variable
pipeline {
    stages {
        stage('Terraform Plan') {
            steps {
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    sh '''
                        export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
                        export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
                        export AWS_DEFAULT_REGION="us-east-1"
                        terraform plan
                    '''
                }
            }
        }
    }
}

Configurar credenciales de Azure

// Para Azure, necesitas el Service Principal
pipeline {
    stages {
        stage('Terraform Azure') {
            steps {
                withCredentials([
                    string(credentialsId: 'azure-client-id', variable: 'ARM_CLIENT_ID'),
                    string(credentialsId: 'azure-client-secret', variable: 'ARM_CLIENT_SECRET'),
                    string(credentialsId: 'azure-tenant-id', variable: 'ARM_TENANT_ID'),
                    string(credentialsId: 'azure-subscription-id', variable: 'ARM_SUBSCRIPTION_ID')
                ]) {
                    sh '''
                        export ARM_CLIENT_ID="${ARM_CLIENT_ID}"
                        export ARM_CLIENT_SECRET="${ARM_CLIENT_SECRET}"
                        export ARM_TENANT_ID="${ARM_TENANT_ID}"
                        export ARM_SUBSCRIPTION_ID="${ARM_SUBSCRIPTION_ID}"
                        terraform plan
                    '''
                }
            }
        }
    }
}

Tabla: Etapas del pipeline con descripción y comandos

Etapa Descripción Comando principal Condición
Checkout Obtiene el código del repositorio Git git checkout (automático) Siempre
Terraform Init Inicializa el workspace, descarga providers y configura el backend terraform init Siempre
Format Check Verifica que el código sigue el formato estándar de Terraform terraform fmt -check -recursive Siempre
Validate Verifica la sintaxis y consistencia interna del código terraform validate Siempre
Plan Genera el plan de cambios, lo muestra y lo guarda como artefacto terraform plan -out=tfplan Siempre
Approval Pausa el pipeline y espera confirmación manual de un humano input() en Groovy Solo en prod
Apply Aplica los cambios del plan guardado en la infraestructura terraform apply tfplan Solo en main
Destroy Destruye toda la infraestructura (acción peligrosa, requiere confirmación) terraform destroy -auto-approve Manual/explícito
Notificación Envía el resultado del pipeline a Slack, email u otro canal Script personalizado Siempre

Jenkinsfile completo y explicado línea por línea

// Jenkinsfile
// Pipeline Declarativo de Terraform para un proyecto profesional

pipeline {
    // ─────────────────────────────────────────────────
    // AGENTE: Define dónde se ejecuta el pipeline
    // ─────────────────────────────────────────────────
    agent {
        // Usamos un nodo con la etiqueta "terraform"
        // Este nodo debe tener Terraform instalado
        label 'terraform'

        // Alternativa: usar Docker
        // docker { image 'hashicorp/terraform:1.9.0' }
    }

    // ─────────────────────────────────────────────────
    // PARÁMETROS: Permiten configurar el pipeline al ejecutarlo
    // ─────────────────────────────────────────────────
    parameters {
        // Selección del ambiente: staging o produccion
        choice(
            name: 'ENVIRONMENT',
            choices: ['staging', 'produccion'],
            description: 'Ambiente de despliegue'
        )

        // Acción a realizar
        choice(
            name: 'ACTION',
            choices: ['plan', 'apply', 'destroy'],
            description: 'Acción de Terraform a ejecutar'
        )

        // Opción para forzar el apply sin aprobación (solo para emergencias)
        booleanParam(
            name: 'AUTO_APPROVE',
            defaultValue: false,
            description: 'Aplicar sin aprobación manual (usar con precaución)'
        )
    }

    // ─────────────────────────────────────────────────
    // VARIABLES DE ENTORNO: Disponibles en todas las etapas
    // ─────────────────────────────────────────────────
    environment {
        // Directorio de trabajo de Terraform
        TF_DIR = 'infrastructure'

        // Versión de Terraform a usar
        TF_VERSION = '1.9.0'

        // Región de AWS
        AWS_DEFAULT_REGION = 'us-east-1'

        // Archivo de variables según el ambiente
        // Esto usa la función params para acceder a los parámetros
        TF_VAR_FILE = "environments/${params.ENVIRONMENT}.tfvars"

        // Clave del estado en S3 según el ambiente
        TF_STATE_KEY = "${params.ENVIRONMENT}/terraform.tfstate"

        // Nombre del plan guardado
        TF_PLAN_FILE = 'tfplan'

        // Desactivar la creación de archivos de backup del estado
        TF_BACKUP = '-backup="-"'
    }

    // ─────────────────────────────────────────────────
    // OPCIONES: Configuración general del pipeline
    // ─────────────────────────────────────────────────
    options {
        // Tiempo máximo de ejecución del pipeline completo: 1 hora
        timeout(time: 1, unit: 'HOURS')

        // Mantener solo los últimos 10 builds en el historial
        buildDiscarder(logRotator(numToKeepStr: '10'))

        // Añadir timestamps a cada línea del log
        timestamps()

        // No permitir ejecuciones concurrentes del mismo pipeline
        disableConcurrentBuilds()

        // Colorear el output de la consola
        ansiColor('xterm')
    }

    // ─────────────────────────────────────────────────
    // ETAPAS: El corazón del pipeline
    // ─────────────────────────────────────────────────
    stages {

        // ── ETAPA 1: CHECKOUT ──────────────────────────
        stage('Checkout') {
            steps {
                // cleanWs() limpia el workspace antes de empezar
                cleanWs()

                // checkout scm clona el repositorio configurado en el job
                checkout scm

                // Mostrar información del commit actual
                sh '''
                    echo "======================================"
                    echo "Información del build:"
                    echo "  Branch: $(git rev-parse --abbrev-ref HEAD)"
                    echo "  Commit: $(git rev-parse --short HEAD)"
                    echo "  Autor:  $(git log -1 --format='%an')"
                    echo "  Fecha:  $(git log -1 --format='%ai')"
                    echo "  Mensaje: $(git log -1 --format='%s')"
                    echo "======================================"
                '''
            }
        }

        // ── ETAPA 2: TERRAFORM INIT ────────────────────
        stage('Terraform Init') {
            steps {
                // Usar las credenciales de AWS almacenadas en Jenkins
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY'),
                    string(credentialsId: 'tf-state-bucket', variable: 'TF_STATE_BUCKET')
                ]) {
                    dir("${TF_DIR}") {
                        // dir() cambia al directorio especificado para ejecutar los comandos
                        sh '''
                            echo "Inicializando Terraform..."
                            terraform version

                            # Inicializar con configuración del backend dinámicamente
                            # Esto permite usar el mismo código para múltiples ambientes
                            terraform init \
                                -backend-config="bucket=${TF_STATE_BUCKET}" \
                                -backend-config="key=${TF_STATE_KEY}" \
                                -backend-config="region=${AWS_DEFAULT_REGION}" \
                                -backend-config="dynamodb_table=terraform-state-locks" \
                                -backend-config="encrypt=true" \
                                -reconfigure \
                                -input=false

                            echo "Terraform init completado exitosamente"
                        '''
                    }
                }
            }
        }

        // ── ETAPA 3: FORMAT CHECK ──────────────────────
        stage('Terraform Format Check') {
            steps {
                dir("${TF_DIR}") {
                    sh '''
                        echo "Verificando formato del código Terraform..."

                        # -check: no modifica archivos, solo verifica y retorna error si hay diferencias
                        # -recursive: verifica subdirectorios también
                        # -diff: muestra exactamente qué cambiaría
                        terraform fmt -check -recursive -diff

                        echo "✓ Formato del código correcto"
                    '''
                }
            }
            // Si el formato es incorrecto, el pipeline falla aquí
            // El desarrollador debe ejecutar 'terraform fmt' localmente y hacer commit
        }

        // ── ETAPA 4: TERRAFORM VALIDATE ───────────────
        stage('Terraform Validate') {
            steps {
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh '''
                            echo "Validando configuración de Terraform..."

                            # validate verifica:
                            # - Sintaxis HCL correcta
                            # - Tipos de argumentos correctos
                            # - Referencias entre recursos válidas
                            # - Módulos y providers correctamente declarados
                            terraform validate

                            echo "✓ Configuración válida"
                        '''
                    }
                }
            }
        }

        // ── ETAPA 5: TERRAFORM PLAN ────────────────────
        stage('Terraform Plan') {
            steps {
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh '''
                            echo "Generando plan de Terraform para ${ENVIRONMENT}..."

                            # -var-file: archivo de variables para el ambiente
                            # -out: guarda el plan en un archivo binario para aplicarlo después
                            # -no-color: desactiva colores para mejor legibilidad en logs
                            # -input=false: no pedir input interactivo (modo CI)
                            terraform plan \
                                -var-file="${TF_VAR_FILE}" \
                                -out="${TF_PLAN_FILE}" \
                                -no-color \
                                -input=false \
                                2>&1 | tee plan-output.txt

                            echo "Plan generado y guardado en ${TF_PLAN_FILE}"
                        '''

                        // Mostrar el plan también en formato JSON (para análisis)
                        sh '''
                            terraform show -json "${TF_PLAN_FILE}" > plan.json
                            echo "Plan en formato JSON guardado en plan.json"

                            # Mostrar resumen de cambios
                            echo "======================================"
                            echo "RESUMEN DE CAMBIOS:"
                            grep -E "^Plan:|will be created|will be destroyed|will be updated" \
                                plan-output.txt || true
                            echo "======================================"
                        '''
                    }
                }
            }
            post {
                always {
                    // Archivar el plan y el output como artefactos del build
                    // Esto permite descargarlos para revisión
                    dir("${TF_DIR}") {
                        archiveArtifacts artifacts: "${TF_PLAN_FILE}, plan-output.txt, plan.json",
                                         fingerprint: true,
                                         allowEmptyArchive: false
                    }
                }
            }
        }

        // ── ETAPA 6: APROBACIÓN MANUAL ────────────────
        stage('Approval') {
            // Esta etapa solo se ejecuta si:
            // 1. La acción es "apply" o "destroy"
            // 2. El ambiente es "produccion"
            // 3. No se activó AUTO_APPROVE
            when {
                allOf {
                    expression { params.ACTION != 'plan' }
                    expression { params.ENVIRONMENT == 'produccion' }
                    expression { !params.AUTO_APPROVE }
                }
            }
            steps {
                // Notificar que se requiere aprobación
                script {
                    // Enviar notificación a Slack antes de esperar
                    slackSend(
                        color: 'warning',
                        message: """
                            :hourglass: *Aprobación requerida*
                            Pipeline: ${env.JOB_NAME} #${env.BUILD_NUMBER}
                            Ambiente: *${params.ENVIRONMENT}*
                            Acción: *${params.ACTION}*
                            Aprueba aquí: ${env.BUILD_URL}input
                        """,
                        channel: '#infra-aprobaciones'
                    )
                }

                // El input detiene el pipeline y espera la aprobación
                // Cualquier persona con acceso a Jenkins puede aprobar o rechazar
                input(
                    message: "¿Aprobar terraform ${params.ACTION} en ${params.ENVIRONMENT}?",
                    ok: "Sí, aprobar",
                    submitter: "admin,tech-lead,devops-manager",
                    // timeout: si nadie aprueba en 24h, falla automáticamente
                    // (configurado en las opciones del pipeline)
                    parameters: [
                        string(
                            name: 'MOTIVO',
                            description: 'Razón para aprobar (opcional)',
                            defaultValue: 'Aprobado para despliegue'
                        )
                    ]
                )
            }
        }

        // ── ETAPA 7: TERRAFORM APPLY ───────────────────
        stage('Terraform Apply') {
            // Solo ejecutar si la acción es "apply"
            when {
                expression { params.ACTION == 'apply' }
            }
            steps {
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh '''
                            echo "Aplicando plan de Terraform en ${ENVIRONMENT}..."

                            # Aplicar el plan guardado exactamente como fue revisado
                            # Esto garantiza que se aplica lo que se aprobó, no algo diferente
                            terraform apply \
                                -input=false \
                                "${TF_PLAN_FILE}"

                            echo "Terraform apply completado exitosamente"

                            # Mostrar los outputs de Terraform
                            echo "======================================"
                            echo "OUTPUTS:"
                            terraform output -no-color
                            echo "======================================"
                        '''
                    }
                }
            }
        }

        // ── ETAPA 8: TERRAFORM DESTROY ─────────────────
        stage('Terraform Destroy') {
            // Solo ejecutar si la acción es "destroy"
            when {
                expression { params.ACTION == 'destroy' }
            }
            steps {
                withCredentials([
                    string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh '''
                            echo "DESTRUYENDO infraestructura en ${ENVIRONMENT}..."
                            echo "Esta acción es IRREVERSIBLE"

                            terraform destroy \
                                -var-file="${TF_VAR_FILE}" \
                                -auto-approve \
                                -input=false \
                                -no-color

                            echo "Infraestructura destruida"
                        '''
                    }
                }
            }
        }

    } // fin stages

    // ─────────────────────────────────────────────────
    // POST: Acciones que se ejecutan después del pipeline
    // ─────────────────────────────────────────────────
    post {
        // Se ejecuta siempre, sin importar el resultado
        always {
            echo "Pipeline finalizado. Estado: ${currentBuild.currentResult}"

            // Limpiar el workspace para no ocupar espacio en disco
            cleanWs(
                cleanWhenNotBuilt: false,
                deleteDirs: true,
                disableDeferredWipeout: true,
                notFailBuild: true
            )
        }

        // Se ejecuta solo si el pipeline fue exitoso
        success {
            script {
                slackSend(
                    color: 'good',
                    message: """
                        :white_check_mark: *Pipeline exitoso*
                        Job: ${env.JOB_NAME} #${env.BUILD_NUMBER}
                        Ambiente: *${params.ENVIRONMENT}*
                        Acción: *${params.ACTION}*
                        Duración: ${currentBuild.durationString}
                        Ver detalles: ${env.BUILD_URL}
                    """,
                    channel: '#infra-deployments'
                )

                // Enviar email de éxito
                emailext(
                    subject: "✅ Terraform ${params.ACTION} exitoso en ${params.ENVIRONMENT}",
                    body: """
                        El pipeline ${env.JOB_NAME} #${env.BUILD_NUMBER} completó exitosamente.

                        Ambiente: ${params.ENVIRONMENT}
                        Acción: ${params.ACTION}
                        Duración: ${currentBuild.durationString}

                        Ver detalles en: ${env.BUILD_URL}
                    """,
                    to: '[email protected]',
                    mimeType: 'text/plain'
                )
            }
        }

        // Se ejecuta solo si el pipeline falló
        failure {
            script {
                slackSend(
                    color: 'danger',
                    message: """
                        :x: *Pipeline FALLIDO*
                        Job: ${env.JOB_NAME} #${env.BUILD_NUMBER}
                        Ambiente: *${params.ENVIRONMENT}*
                        Acción: *${params.ACTION}*
                        Ver logs: ${env.BUILD_URL}console
                    """,
                    channel: '#infra-alertas'
                )
            }
        }

        // Se ejecuta si el pipeline fue abortado (ej: timeout en aprobación)
        aborted {
            script {
                slackSend(
                    color: 'warning',
                    message: """
                        :no_entry: *Pipeline abortado*
                        Job: ${env.JOB_NAME} #${env.BUILD_NUMBER}
                        Ambiente: *${params.ENVIRONMENT}*
                        Puede ser que la aprobación manual haya expirado.
                    """,
                    channel: '#infra-alertas'
                )
            }
        }

    } // fin post

} // fin pipeline

Archivar el plan de Terraform como artefacto

Los artefactos en Jenkins son archivos que se guardan junto al build y pueden descargarse posteriormente. Para Terraform, es importante archivar:

  1. El archivo de plan (tfplan): permite aplicar exactamente lo que se revisó
  2. El output del plan en texto (plan-output.txt): legible por humanos para auditoría
  3. El plan en JSON (plan.json): para análisis programático o herramientas de policy
// Archivar artefactos de Terraform
post {
    always {
        // Archivar los archivos del plan
        archiveArtifacts(
            artifacts: 'infrastructure/tfplan, infrastructure/plan-output.txt, infrastructure/plan.json',
            fingerprint: true,      // Crear fingerprint para rastrear el artefacto
            allowEmptyArchive: true // No fallar si los archivos no existen
        )

        // Publicar el plan como HTML para mejor visualización
        // (requiere plugin de HTML Publisher)
        publishHTML([
            allowMissing: true,
            alwaysLinkToLastBuild: true,
            keepAll: true,
            reportDir: 'infrastructure',
            reportFiles: 'plan-output.txt',
            reportName: 'Terraform Plan'
        ])
    }
}

Usar Jenkins Pipeline con parameters para múltiples acciones

Los parámetros permiten que el mismo Jenkinsfile sirva para plan, apply y destroy:

// Ejemplo de uso de parámetros para controlar la acción
parameters {
    choice(
        name: 'ENVIRONMENT',
        choices: ['staging', 'produccion'],
        description: 'Ambiente objetivo'
    )
    choice(
        name: 'ACTION',
        choices: ['plan', 'apply', 'destroy'],
        description: '''
            plan: Solo genera el plan sin aplicar cambios
            apply: Aplica los cambios (requiere aprobación en producción)
            destroy: DESTRUYE toda la infraestructura del ambiente
        '''
    )
    string(
        name: 'TF_VERSION',
        defaultValue: '1.9.0',
        description: 'Versión de Terraform a usar'
    )
    booleanParam(
        name: 'REFRESH_STATE',
        defaultValue: false,
        description: 'Actualizar el estado antes de planificar'
    )
}

// Usar los parámetros en las etapas
stage('Terraform Plan') {
    steps {
        sh """
            # Usar la versión de Terraform especificada
            tfenv use ${params.TF_VERSION}

            # Opcional: refresh del estado
            if [ "${params.REFRESH_STATE}" = "true" ]; then
                terraform refresh -var-file="${params.ENVIRONMENT}.tfvars"
            fi

            terraform plan \\
                -var-file="${params.ENVIRONMENT}.tfvars" \\
                -out=tfplan
        """
    }
}

Integración con Slack y email para notificaciones

Configuración del plugin de Slack en Jenkins

  1. Instala el plugin Slack Notification en Jenkins
  2. Ve a Manage Jenkins > Configure System > Slack
  3. Configura:
    • Workspace: nombre de tu workspace de Slack
    • Default channel: canal por defecto para notificaciones
    • Credential: token del bot de Slack (guardado como credencial en Jenkins)
// Notificaciones a Slack en diferentes momentos del pipeline
pipeline {
    stages {
        stage('Terraform Plan') {
            steps {
                script {
                    // Notificar inicio
                    slackSend(
                        channel: '#infra-builds',
                        color: '#0000FF',  // Azul para información
                        message: "🔄 Iniciando terraform plan en *${params.ENVIRONMENT}*\n" +
                                 "Build: ${env.BUILD_URL}"
                    )
                }

                sh 'terraform plan -out=tfplan -no-color 2>&1 | tee plan-output.txt'

                script {
                    // Leer el resumen del plan
                    def planOutput = readFile('plan-output.txt')
                    def planSummary = planOutput.readLines()
                        .findAll { it.contains('Plan:') || it.contains('No changes') }
                        .join('\n')

                    // Notificar resultado del plan
                    slackSend(
                        channel: '#infra-builds',
                        color: 'good',
                        message: "✅ Plan completado en *${params.ENVIRONMENT}*\n```${planSummary}```"
                    )
                }
            }
        }
    }

    post {
        success {
            slackSend(channel: '#infra-deployments', color: 'good',
                message: "✅ *${params.ACTION}* exitoso en *${params.ENVIRONMENT}*")

            // Email con el log completo
            emailext(
                subject: "Terraform ${params.ACTION} - ${params.ENVIRONMENT} - EXITOSO",
                body: '${SCRIPT, template="groovy-html.template"}',  // Template HTML
                to: '${DEFAULT_RECIPIENTS}',
                attachLog: true  // Adjuntar el log completo
            )
        }

        failure {
            slackSend(channel: '#infra-alertas', color: 'danger',
                message: "❌ *${params.ACTION}* FALLIDO en *${params.ENVIRONMENT}*\n${env.BUILD_URL}console")

            emailext(
                subject: "ALERTA: Terraform ${params.ACTION} - ${params.ENVIRONMENT} - FALLIDO",
                body: '${SCRIPT, template="groovy-html.template"}',
                to: '${DEFAULT_RECIPIENTS}',
                attachLog: true
            )
        }
    }
}

Ejercicio práctico

Enunciado

Adapta el Jenkinsfile para soportar múltiples ambientes con las siguientes características:

  1. Tres ambientes: desarrollo, staging y produccion
  2. En desarrollo: apply automático sin aprobación
  3. En staging: apply automático sin aprobación solo si la rama es main
  4. En produccion: siempre requiere aprobación manual de al menos un miembro del equipo tech-leads
  5. En produccion, el timeout de aprobación debe ser de 4 horas
  6. Cada ambiente tiene sus propias credenciales de AWS (3 pares de claves)
  7. El estado de cada ambiente se guarda en la misma bucket S3 pero con prefijos diferentes

Solución detallada

// Jenkinsfile - Solución del ejercicio multi-ambiente
pipeline {

    agent { label 'terraform' }

    parameters {
        choice(
            name: 'ENVIRONMENT',
            choices: ['desarrollo', 'staging', 'produccion'],
            description: 'Ambiente de despliegue'
        )
        choice(
            name: 'ACTION',
            choices: ['plan', 'apply', 'destroy'],
            description: 'Acción a ejecutar'
        )
    }

    environment {
        // Configuración base
        TF_DIR = 'infrastructure'
        AWS_DEFAULT_REGION = 'us-east-1'
        TF_STATE_BUCKET = 'mi-empresa-terraform-states'

        // Derivar el ID de credenciales según el ambiente
        // Esto permite tener credenciales separadas por ambiente
        AWS_CREDENTIALS_ID = "aws-credentials-${params.ENVIRONMENT}"

        // Clave del estado en S3 con prefijo del ambiente
        TF_STATE_KEY = "${params.ENVIRONMENT}/terraform.tfstate"
    }

    options {
        buildDiscarder(logRotator(numToKeepStr: '20'))
        timestamps()
        disableConcurrentBuilds()
        ansiColor('xterm')
    }

    stages {

        stage('Checkout') {
            steps {
                cleanWs()
                checkout scm
                sh 'git log --oneline -5'
            }
        }

        stage('Validar Rama para Staging') {
            // En staging, solo se puede hacer apply desde la rama main
            when {
                allOf {
                    expression { params.ENVIRONMENT == 'staging' }
                    expression { params.ACTION == 'apply' }
                    not { branch 'main' }
                }
            }
            steps {
                error("Error: En staging, terraform apply solo está permitido desde la rama 'main'. " +
                      "Rama actual: ${env.GIT_BRANCH}")
            }
        }

        stage('Terraform Init') {
            steps {
                withCredentials([
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-key-id",
                           variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-secret",
                           variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh """
                            echo "Inicializando Terraform para ambiente: ${params.ENVIRONMENT}"

                            terraform init \\
                                -backend-config="bucket=${TF_STATE_BUCKET}" \\
                                -backend-config="key=${TF_STATE_KEY}" \\
                                -backend-config="region=${AWS_DEFAULT_REGION}" \\
                                -backend-config="dynamodb_table=terraform-state-locks" \\
                                -backend-config="encrypt=true" \\
                                -reconfigure \\
                                -input=false

                            echo "Init completado para ${params.ENVIRONMENT}"
                        """
                    }
                }
            }
        }

        stage('Terraform Format y Validate') {
            steps {
                dir("${TF_DIR}") {
                    sh '''
                        echo "=== Verificando formato ==="
                        terraform fmt -check -recursive -diff

                        echo "=== Validando configuración ==="
                        terraform validate

                        echo "=== Validación completada ==="
                    '''
                }
            }
        }

        stage('Terraform Plan') {
            steps {
                withCredentials([
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-key-id",
                           variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-secret",
                           variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh """
                            echo "Generando plan para: ${params.ENVIRONMENT}"

                            terraform plan \\
                                -var-file="environments/${params.ENVIRONMENT}.tfvars" \\
                                -out=tfplan \\
                                -no-color \\
                                -input=false \\
                                2>&1 | tee plan-output.txt
                        """
                    }
                }
            }
            post {
                always {
                    dir("${TF_DIR}") {
                        archiveArtifacts artifacts: 'tfplan, plan-output.txt',
                                         fingerprint: true,
                                         allowEmptyArchive: true
                    }
                }
            }
        }

        stage('Aprobación Manual - Solo Producción') {
            when {
                allOf {
                    expression { params.ENVIRONMENT == 'produccion' }
                    expression { params.ACTION != 'plan' }
                }
            }
            // Timeout de 4 horas para la aprobación
            options {
                timeout(time: 4, unit: 'HOURS')
            }
            steps {
                script {
                    // Notificar que se requiere aprobación
                    slackSend(
                        color: 'warning',
                        channel: '#tech-leads',
                        message: """
                            ⚠️ *Se requiere aprobación para PRODUCCIÓN*
                            Acción: *${params.ACTION}*
                            Build: ${env.JOB_NAME} #${env.BUILD_NUMBER}
                            Aprobar en: ${env.BUILD_URL}input
                            *Expira en 4 horas*
                        """
                    )
                }

                // Solicitar aprobación - solo miembros del grupo tech-leads pueden aprobar
                input(
                    message: "¿Aprobar terraform ${params.ACTION} en PRODUCCIÓN?",
                    ok: 'Aprobar para Producción',
                    submitter: 'tech-leads',  // Grupo de Jenkins
                    submitterParameter: 'APPROVED_BY'
                )

                echo "Aprobado. El apply procederá."
            }
        }

        stage('Terraform Apply') {
            when {
                expression { params.ACTION == 'apply' }
            }
            steps {
                withCredentials([
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-key-id",
                           variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-secret",
                           variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh """
                            echo "Aplicando cambios en: ${params.ENVIRONMENT}"
                            terraform apply -input=false tfplan
                            echo "Apply completado en ${params.ENVIRONMENT}"

                            echo "=== OUTPUTS ==="
                            terraform output -no-color
                        """
                    }
                }
            }
        }

        stage('Terraform Destroy') {
            when {
                expression { params.ACTION == 'destroy' }
            }
            steps {
                withCredentials([
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-key-id",
                           variable: 'AWS_ACCESS_KEY_ID'),
                    string(credentialsId: "${AWS_CREDENTIALS_ID}-secret",
                           variable: 'AWS_SECRET_ACCESS_KEY')
                ]) {
                    dir("${TF_DIR}") {
                        sh """
                            echo "DESTRUYENDO infraestructura en: ${params.ENVIRONMENT}"
                            terraform destroy \\
                                -var-file="environments/${params.ENVIRONMENT}.tfvars" \\
                                -auto-approve \\
                                -input=false \\
                                -no-color
                        """
                    }
                }
            }
        }

    } // fin stages

    post {
        success {
            script {
                def mensaje = "✅ Terraform *${params.ACTION}* exitoso\n" +
                              "Ambiente: *${params.ENVIRONMENT}*\n" +
                              "Build: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
                slackSend(color: 'good', channel: '#infra-deployments', message: mensaje)
            }
        }

        failure {
            script {
                def canal = params.ENVIRONMENT == 'produccion' ? '#alertas-criticas' : '#infra-alertas'
                slackSend(
                    color: 'danger',
                    channel: canal,
                    message: "❌ Terraform *${params.ACTION}* FALLIDO en *${params.ENVIRONMENT}*\n${env.BUILD_URL}console"
                )
            }
        }

        always {
            cleanWs()
        }
    }

} // fin pipeline

Errores comunes con Jenkins y Terraform

Error 1: Permisos insuficientes en el agente

Problema: Permission denied: /usr/local/bin/terraform

Solución:

# Verificar permisos del binario
ls -la /usr/local/bin/terraform

# Corregir permisos
sudo chmod +x /usr/local/bin/terraform
sudo chown jenkins:jenkins /usr/local/bin/terraform

Error 2: Variables de entorno no disponibles entre etapas

Problema: Una variable definida en un sh no está disponible en el siguiente sh.

// MAL - Las variables de shell no persisten entre bloques sh
stage('Setup') {
    steps {
        sh 'export MI_VAR="valor"'  // Esta variable desaparece
    }
}
stage('Use') {
    steps {
        sh 'echo $MI_VAR'  // Error: variable vacía
    }
}

// BIEN - Usar environment del pipeline para compartir variables
pipeline {
    environment {
        MI_VAR = 'valor'  // Disponible en todas las etapas
    }
}

// O bien, usar scripts de Groovy para compartir datos
stage('Setup') {
    steps {
        script {
            env.MI_VAR = sh(script: 'echo "valor"', returnStdout: true).trim()
        }
    }
}
stage('Use') {
    steps {
        sh 'echo $MI_VAR'  // Ahora funciona
    }
}

Error 3: El workspace no se limpia entre builds

Problema: Archivos de builds anteriores causan comportamiento inesperado.

// BIEN - Limpiar siempre al inicio
stage('Checkout') {
    steps {
        cleanWs()  // Limpia el workspace
        checkout scm
    }
}

// También limpiar al final
post {
    always {
        cleanWs(
            cleanWhenNotBuilt: false,
            deleteDirs: true,
            disableDeferredWipeout: true
        )
    }
}

Error 4: Bloqueo del estado de Terraform no liberado

Problema: Un build anterior falló sin liberar el lock de DynamoDB.

# Error: Error locking state: Error acquiring the state lock
# ConditionalCheckFailedException: The conditional request failed

# Solución 1: Usar terraform force-unlock
terraform force-unlock LOCK_ID

# El LOCK_ID aparece en el mensaje de error:
# Lock Info:
#   ID:        20060102150405000000000
#   Path:      mi-bucket/staging/terraform.tfstate
#   Operation: OperationTypePlan
#   Who:       jenkins@build-server
#   Version:   1.9.0
#   Created:   2024-01-15 10:30:00 +0000 UTC

# Solución 2: Eliminar el item de DynamoDB manualmente
aws dynamodb delete-item \
    --table-name terraform-state-locks \
    --key '{"LockID": {"S": "mi-bucket/staging/terraform.tfstate"}}'

Error 5: Jenkinsfile con sintaxis incorrecta

Problema: El pipeline no se ejecuta porque hay errores de sintaxis en el Jenkinsfile.

# Validar el Jenkinsfile antes de hacer commit
# Usando la API REST de Jenkins
curl -X POST \
    -F "jenkinsfile=<Jenkinsfile" \
    http://jenkins.empresa.com/pipeline-model-converter/validate

# Respuesta exitosa:
# Jenkinsfile successfully validated.

# Con errores:
# Errors encountered validating Jenkinsfile:
# ...

Resumen

En este módulo aprendiste a:

  • Instalar y configurar Terraform en agentes Jenkins usando múltiples estrategias (instalación directa, Docker, tfenv).
  • Gestionar credenciales de nube de forma segura con el sistema de credenciales de Jenkins, sin exponer secretos en los logs ni en el código.
  • Construir un Jenkinsfile declarativo completo con todas las etapas necesarias: Checkout, Init, Format, Validate, Plan, Approval y Apply, con cada línea explicada.
  • Implementar aprobaciones manuales con el paso input(), control de quién puede aprobar y timeout configurable.
  • Archivar artefactos del plan de Terraform para auditoría y reproducibilidad.
  • Integrar notificaciones con Slack y email para mantener al equipo informado.
  • Adaptar el pipeline para múltiples ambientes con credenciales y reglas de aprobación diferentes según el ambiente.
  • Resolver errores comunes: permisos, variables de entorno, limpieza del workspace y locks de estado.

En el siguiente módulo veremos cómo lograr el mismo resultado usando GitHub Actions, una solución más moderna que elimina la necesidad de mantener un servidor Jenkins propio, ideal para proyectos alojados en GitHub.

© Copyright 2026. Todos los derechos reservados