Updated Jenkinsfile
All checks were successful
Tests / CI / Run tests passed: 206
expenses_manager/pipeline/head This commit looks good

This commit is contained in:
JKuijperM 2026-09-30 15:30:34 +02:00
parent 80f56245dd
commit 9df4988cc7

View File

@ -1,143 +1,264 @@
pipeline { pipeline {
agent any // agent none at the top so the approval stage can wait without holding an
// executor. Stages that need a workspace declare their own agent.
agent none
environment { environment {
// DEBUG=True keeps settings.py from enabling SECURE_SSL_REDIRECT, which
// makes Django's test client receive a 301 on every request.
DEBUG = 'True' DEBUG = 'True'
SECRET_KEY = 'ci-only-value-never-used-in-production' SECRET_KEY = 'ci-only-value-never-used-in-production'
DJANGO_SETTINGS_MODULE = 'expenses_manager.settings' DJANGO_SETTINGS_MODULE = 'expenses_manager.settings'
PYTHONUNBUFFERED = '1' PYTHONUNBUFFERED = '1'
// Jenkins has no Docker daemon, so images are built on the NAS over SSH. // Jenkins has no Docker daemon, so everything Docker-related happens on
// Absolute paths are required: the remote non-interactive PATH is // the NAS over SSH. Absolute paths are required: the remote
// /usr/bin:/bin:/usr/sbin:/sbin and does not include /usr/local/bin. // non-interactive PATH does not include /usr/local/bin.
NAS_HOST = '192.168.1.140' NAS_HOST = '192.168.1.140'
NAS_USER = 'jenkins-ci' NAS_USER = 'jenkins-ci'
NAS_DOCKER = '/usr/local/bin/docker' NAS_DOCKER = '/usr/local/bin/docker'
NAS_COMPOSE = '/usr/local/bin/docker-compose'
NAS_BACKUP = '/volume1/scripts/backup_finanzas_db.sh'
NAS_NETWORK = 'finanzas_default'
CI_DIR = '/volume1/docker/ci/expenses_manager' CI_DIR = '/volume1/docker/ci/expenses_manager'
COMPOSE_DIR = '/volume1/docker/finanzas'
APP_ENV = '/volume1/docker/finanzas/app/expenses_manager/.env'
SSH_OPTS = '-o StrictHostKeyChecking=accept-new' SSH_OPTS = '-o StrictHostKeyChecking=accept-new'
IMAGE = 'expenses-manager'
// Deliberately not "finanzas-web": that name belongs to the image
// docker-compose builds for production, and CI must never overwrite it.
IMAGE = 'expenses-manager-ci'
} }
options { options {
buildDiscarder(logRotator(numToKeepStr: '30')) buildDiscarder(logRotator(numToKeepStr: '30'))
timeout(time: 30, unit: 'MINUTES')
} }
stages { stages {
stage('Setup Python') {
steps {
sh '''
cd expenses_manager
python --version
python -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -r requirements.txt
'''
}
}
// Catches models changed without generating the migration. Exactly the // Nested stages share one agent allocation, so the venv built in the
// failure mode that reached production as a 500 on 2026-09-29. // first stage is still there for the ones that follow.
stage('Check migrations') { stage('CI') {
steps { agent any
sh ''' options { timeout(time: 30, unit: 'MINUTES') }
cd expenses_manager stages {
venv/bin/python manage.py makemigrations --check --dry-run
'''
}
}
stage('Run tests') { stage('Setup Python') {
steps { steps {
sh ''' sh '''
cd expenses_manager cd expenses_manager
venv/bin/pytest --cov --junitxml=junit.xml python --version
''' python -m venv venv
} venv/bin/pip install --upgrade pip
} venv/bin/pip install -r requirements.txt
'''
}
}
stage('Build image') { // Catches models changed without generating the migration.
steps { stage('Check migrations') {
sshagent(credentials: ['nas-ssh']) { steps {
sh ''' sh '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) cd expenses_manager
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-') venv/bin/python manage.py makemigrations --check --dry-run
'''
}
}
# Ship the workspace that was just tested, rather than stage('Run tests') {
# cloning again on the NAS: what gets built is then steps {
# provably the same code the tests ran against. sh '''
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ cd expenses_manager
"rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}" venv/bin/pytest --cov --junitxml=junit.xml
'''
}
post {
always {
// allowEmptyResults: false is deliberate. A build where
// pytest died before collecting anything produces no XML,
// and that must fail rather than pass quietly.
junit allowEmptyResults: false,
testResults: 'expenses_manager/junit.xml'
}
}
}
tar -czf - --exclude=./expenses_manager/venv \ stage('Build image') {
--exclude=./.git . \ steps {
| ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ sshagent(credentials: ['nas-ssh']) {
"tar -xzf - -C ${CI_DIR}" sh '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-')
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ # Ship the workspace that was just tested rather than
"cd ${CI_DIR} && sudo ${NAS_DOCKER} build \ # cloning again: what gets built is then provably the
-t ${IMAGE}:${SHA} -t ${IMAGE}:${TAG} ." # same code the tests ran against.
''' ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}"
tar -czf - --exclude=./expenses_manager/venv \
--exclude=./.git . \
| ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"tar -xzf - -C ${CI_DIR}"
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"cd ${CI_DIR} && sudo ${NAS_DOCKER} build \
-t ${IMAGE}:${SHA} -t ${IMAGE}:${TAG} ."
'''
}
}
}
// A successful build only proves the Dockerfile is syntactically
// sound. Django's own checks prove the image can actually start.
stage('Smoke test image') {
steps {
sshagent(credentials: ['nas-ssh']) {
sh '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"sudo ${NAS_DOCKER} run --rm \
-e SECRET_KEY=smoke-test-only \
-e DEBUG=False \
-e ALLOWED_HOSTS=localhost \
-e DB_ENGINE=sqlite3 \
${IMAGE}:${SHA} python manage.py check"
'''
}
}
} }
} }
} }
// A successful build only proves the Dockerfile is syntactically sound. // Read-only: nothing is changed on the NAS by this stage.
// Running Django's own checks inside the image proves it can actually stage('Detect migrations') {
// start: settings load, middleware resolves, static files are collected. agent any
stage('Smoke test image') { when { branch 'main' }
steps {
sshagent(credentials: ['nas-ssh']) {
script {
// migrate --check exits non-zero when migrations are unapplied.
def rc = sh(returnStatus: true, script: '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"sudo ${NAS_DOCKER} run --rm \
--network ${NAS_NETWORK} \
--env-file ${APP_ENV} \
${IMAGE}:${SHA} python manage.py migrate --check"
''')
env.MIGRATIONS_PENDING = (rc == 0) ? 'false' : 'true'
echo "Migraciones pendientes: ${env.MIGRATIONS_PENDING}"
if (rc != 0) {
// Printing the plan also proves the database is reachable.
// If the non-zero status above was a connection failure
// rather than pending migrations, this fails loudly
// instead of asking for approval on a false premise.
sh '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"sudo ${NAS_DOCKER} run --rm \
--network ${NAS_NETWORK} \
--env-file ${APP_ENV} \
${IMAGE}:${SHA} python manage.py migrate --plan"
'''
}
}
}
}
}
// No agent: this waits without occupying an executor.
stage('Aprobar migraciones') {
when {
allOf {
branch 'main'
expression { env.MIGRATIONS_PENDING == 'true' }
}
}
steps {
timeout(time: 2, unit: 'HOURS') {
input message: 'Hay migraciones pendientes. Revisa el plan en el log del stage anterior.',
ok: 'Migrar y desplegar'
}
}
}
stage('Deploy') {
agent any
when { branch 'main' }
options { timeout(time: 15, unit: 'MINUTES') }
steps { steps {
sshagent(credentials: ['nas-ssh']) { sshagent(credentials: ['nas-ssh']) {
sh ''' sh '''
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
# Always back up, even with no migrations pending: this is
# the restore point for this particular deployment.
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} "sudo ${NAS_BACKUP}"
# Migrate with the new image before the running container is
# replaced, so the app never serves new code against the old
# schema. A no-op when nothing is pending.
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"sudo ${NAS_DOCKER} run --rm \ "sudo ${NAS_DOCKER} run --rm \
-e SECRET_KEY=smoke-test-only \ --network ${NAS_NETWORK} \
-e DEBUG=False \ --env-file ${APP_ENV} \
-e ALLOWED_HOSTS=localhost \ ${IMAGE}:${SHA} python manage.py migrate --noinput"
-e DB_ENGINE=sqlite3 \
${IMAGE}:${SHA} python manage.py check" # Point compose at the image that was just tested, then swap
# the container. Writing the tag to .env leaves a readable
# record of what is deployed, and makes rollback a one-line
# edit plus "docker-compose up -d web".
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"echo WEB_IMAGE=${IMAGE}:${SHA} > ${COMPOSE_DIR}/.env \
&& cd ${COMPOSE_DIR} \
&& sudo ${NAS_COMPOSE} up -d web"
''' '''
script {
// Confirm the application actually answers. A container that
// starts and immediately crashes would otherwise pass as a
// successful deployment.
def code = sh(returnStdout: true, script: '''
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
"sleep 10 && curl -s -o /dev/null -w '%{http_code}' \
http://127.0.0.1:8001/"
''').trim()
echo "Respuesta HTTP: ${code}"
if (!(code in ['200', '302'])) {
error("La aplicacion no responde correctamente (HTTP ${code})")
}
}
}
}
post {
success {
notifyTelegram("🚀 Desplegado")
} }
} }
} }
} }
post { post {
always { failure { notifyTelegram("❌ FALLO") }
// allowEmptyResults: false is deliberate. A build where pytest died fixed { notifyTelegram("✅ Recuperado") }
// before collecting anything produces no XML, and that must fail aborted { notifyTelegram("⚠️ Abortado") }
// rather than pass quietly.
junit allowEmptyResults: false, testResults: 'expenses_manager/junit.xml'
}
failure {
notifyTelegram("❌ FALLO")
}
fixed {
notifyTelegram("✅ Recuperado")
}
} }
} }
// Sends a one-line build summary to Telegram. Credentials are read from the // Sends a one-line build summary to Telegram. The node block is required because
// Jenkins store so the token never lives in the repository. // the pipeline declares "agent none", so post actions have no workspace of their
// own. Credentials come from the Jenkins store, never from the repository.
void notifyTelegram(String status) { void notifyTelegram(String status) {
withCredentials([ node {
string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'), withCredentials([
string(credentialsId: 'telegram-chat-id', variable: 'TG_CHAT') string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'),
]) { string(credentialsId: 'telegram-chat-id', variable: 'TG_CHAT')
sh """ ]) {
curl -s -X POST "https://api.telegram.org/bot\${TG_TOKEN}/sendMessage" \ sh """
--data-urlencode "chat_id=\${TG_CHAT}" \ curl -s -X POST "https://api.telegram.org/bot\${TG_TOKEN}/sendMessage" \
--data-urlencode "text=${status} ${env.JOB_NAME} #${env.BUILD_NUMBER} --data-urlencode "chat_id=\${TG_CHAT}" \
--data-urlencode "text=${status} ${env.JOB_NAME} #${env.BUILD_NUMBER}
Rama: ${env.BRANCH_NAME ?: 'main'} Rama: ${env.BRANCH_NAME ?: 'main'}
${env.BUILD_URL}" > /dev/null ${env.BUILD_URL}" > /dev/null
""" """
}
} }
} }