From 9df4988cc7adbf9f6837f78cdc077006e1d11ca5 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 30 Sep 2026 15:30:34 +0200 Subject: [PATCH] Updated Jenkinsfile --- expenses_manager/Jenkinsfile | 303 ++++++++++++++++++++++++----------- 1 file changed, 212 insertions(+), 91 deletions(-) diff --git a/expenses_manager/Jenkinsfile b/expenses_manager/Jenkinsfile index 4aa6fb1..551b394 100644 --- a/expenses_manager/Jenkinsfile +++ b/expenses_manager/Jenkinsfile @@ -1,143 +1,264 @@ 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 { - // DEBUG=True keeps settings.py from enabling SECURE_SSL_REDIRECT, which - // makes Django's test client receive a 301 on every request. DEBUG = 'True' SECRET_KEY = 'ci-only-value-never-used-in-production' DJANGO_SETTINGS_MODULE = 'expenses_manager.settings' PYTHONUNBUFFERED = '1' - // Jenkins has no Docker daemon, so images are built on the NAS over SSH. - // Absolute paths are required: the remote non-interactive PATH is - // /usr/bin:/bin:/usr/sbin:/sbin and does not include /usr/local/bin. + // Jenkins has no Docker daemon, so everything Docker-related happens on + // the NAS over SSH. Absolute paths are required: the remote + // non-interactive PATH does not include /usr/local/bin. NAS_HOST = '192.168.1.140' NAS_USER = 'jenkins-ci' 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' + COMPOSE_DIR = '/volume1/docker/finanzas' + APP_ENV = '/volume1/docker/finanzas/app/expenses_manager/.env' SSH_OPTS = '-o StrictHostKeyChecking=accept-new' - - // 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' + IMAGE = 'expenses-manager' } options { buildDiscarder(logRotator(numToKeepStr: '30')) - timeout(time: 30, unit: 'MINUTES') } 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 - // failure mode that reached production as a 500 on 2026-09-29. - stage('Check migrations') { - steps { - sh ''' - cd expenses_manager - venv/bin/python manage.py makemigrations --check --dry-run - ''' - } - } + // Nested stages share one agent allocation, so the venv built in the + // first stage is still there for the ones that follow. + stage('CI') { + agent any + options { timeout(time: 30, unit: 'MINUTES') } + stages { - stage('Run tests') { - steps { - sh ''' - cd expenses_manager - venv/bin/pytest --cov --junitxml=junit.xml - ''' - } - } + 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 + ''' + } + } - stage('Build image') { - steps { - sshagent(credentials: ['nas-ssh']) { - sh ''' - SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) - TAG=$(echo "${BRANCH_NAME}" | tr '/' '-') + // Catches models changed without generating the migration. + stage('Check migrations') { + steps { + sh ''' + cd expenses_manager + venv/bin/python manage.py makemigrations --check --dry-run + ''' + } + } - # Ship the workspace that was just tested, rather than - # cloning again on the NAS: what gets built is then - # provably the same code the tests ran against. - ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ - "rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}" + stage('Run tests') { + steps { + sh ''' + cd expenses_manager + 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 \ - --exclude=./.git . \ - | ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ - "tar -xzf - -C ${CI_DIR}" + stage('Build image') { + steps { + sshagent(credentials: ['nas-ssh']) { + sh ''' + SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) + TAG=$(echo "${BRANCH_NAME}" | tr '/' '-') - ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ - "cd ${CI_DIR} && sudo ${NAS_DOCKER} build \ - -t ${IMAGE}:${SHA} -t ${IMAGE}:${TAG} ." - ''' + # Ship the workspace that was just tested rather than + # cloning again: what gets built is then provably the + # 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. - // Running Django's own checks inside the image proves it can actually - // start: settings load, middleware resolves, static files are collected. - stage('Smoke test image') { + // Read-only: nothing is changed on the NAS by this stage. + stage('Detect migrations') { + agent any + 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 { sshagent(credentials: ['nas-ssh']) { sh ''' 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} \ "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" + --network ${NAS_NETWORK} \ + --env-file ${APP_ENV} \ + ${IMAGE}:${SHA} python manage.py migrate --noinput" + + # 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 { - 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' - } - failure { - notifyTelegram("❌ FALLO") - } - fixed { - notifyTelegram("✅ Recuperado") - } + failure { notifyTelegram("❌ FALLO") } + fixed { notifyTelegram("✅ Recuperado") } + aborted { notifyTelegram("⚠️ Abortado") } } } -// Sends a one-line build summary to Telegram. Credentials are read from the -// Jenkins store so the token never lives in the repository. +// Sends a one-line build summary to Telegram. The node block is required because +// 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) { - withCredentials([ - 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" \ - --data-urlencode "chat_id=\${TG_CHAT}" \ - --data-urlencode "text=${status} ${env.JOB_NAME} #${env.BUILD_NUMBER} + node { + withCredentials([ + 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" \ + --data-urlencode "chat_id=\${TG_CHAT}" \ + --data-urlencode "text=${status} ${env.JOB_NAME} #${env.BUILD_NUMBER} Rama: ${env.BRANCH_NAME ?: 'main'} ${env.BUILD_URL}" > /dev/null - """ + """ + } } } \ No newline at end of file