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:
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.0Opció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 versionConfigurar 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
- Ve a Jenkins > Manage Jenkins > Manage Credentials
- Selecciona el dominio Global
- Haz clic en Add Credentials
- 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 pipelineArchivar 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:
- El archivo de plan (
tfplan): permite aplicar exactamente lo que se revisó - El output del plan en texto (
plan-output.txt): legible por humanos para auditoría - 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
- Instala el plugin Slack Notification en Jenkins
- Ve a Manage Jenkins > Configure System > Slack
- 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:
- Tres ambientes:
desarrollo,stagingyproduccion - En
desarrollo: apply automático sin aprobación - En
staging: apply automático sin aprobación solo si la rama esmain - En
produccion: siempre requiere aprobación manual de al menos un miembro del equipotech-leads - En
produccion, el timeout de aprobación debe ser de 4 horas - Cada ambiente tiene sus propias credenciales de AWS (3 pares de claves)
- 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 pipelineErrores 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/terraformError 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.
Curso de Terraform
Módulo 1: Introducción a Terraform
- ¿Qué es Terraform?
- Instalando Terraform
- Conceptos Básicos de Terraform
- Primera Configuración de Terraform
Módulo 2: Lenguaje de Configuración de Terraform
Módulo 3: Gestión del Estado
Módulo 4: Módulos de Terraform
Módulo 5: Aprovisionamiento de Recursos
- Conceptos Básicos de Aprovisionamiento
- Aprovisionamiento de Recursos AWS
- Aprovisionamiento de Recursos Azure
- Aprovisionamiento de Recursos GCP
Módulo 6: Funcionalidades Avanzadas de Terraform
Módulo 7: Mejores Prácticas de Terraform
- Organización del Código
- Control de Versiones
- Pruebas del Código de Terraform
- Mejores Prácticas de Seguridad
Módulo 8: Terraform en CI/CD
- Integración de Terraform con CI/CD
- Automatización de Terraform con Jenkins
- Uso de Terraform con GitHub Actions
- Terraform Cloud y Enterprise
