diff --git a/expenses_manager/Jenkinsfile b/expenses_manager/Jenkinsfile index 9ccf69c..551b394 100644 --- a/expenses_manager/Jenkinsfile +++ b/expenses_manager/Jenkinsfile @@ -1,50 +1,264 @@ -pipeline{ - agent any +pipeline { + // 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' - DJANGO_SECRET_KEY = 'test-secret-key-for-ci' - DJANGO_SETTINGS_MODULE = "expenses_manager.settings" - PYTHONUNBUFFERED = "1" + SECRET_KEY = 'ci-only-value-never-used-in-production' + DJANGO_SETTINGS_MODULE = 'expenses_manager.settings' + PYTHONUNBUFFERED = '1' + + // 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' + IMAGE = 'expenses-manager' + } + + options { + buildDiscarder(logRotator(numToKeepStr: '30')) } stages { - stage('Checkout') { - steps { - checkout scm + + // 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('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. + stage('Check migrations') { + steps { + sh ''' + cd expenses_manager + venv/bin/python manage.py makemigrations --check --dry-run + ''' + } + } + + 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' + } + } + } + + stage('Build image') { + steps { + sshagent(credentials: ['nas-ssh']) { + sh ''' + SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) + TAG=$(echo "${BRANCH_NAME}" | tr '/' '-') + + # 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" + ''' + } + } + } } } - stage('Setup Python') { + // Read-only: nothing is changed on the NAS by this stage. + stage('Detect migrations') { + agent any + when { branch 'main' } steps { - sh ''' - cd expenses_manager - python --version - python -m venv venv - . venv/bin/activate - pip install --upgrade pip - pip install -r requirements.txt - ''' - } + 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" + ''' + } + } + } + } } - stage('Run tests') { + // No agent: this waits without occupying an executor. + stage('Aprobar migraciones') { + when { + allOf { + branch 'main' + expression { env.MIGRATIONS_PENDING == 'true' } + } + } steps { - sh ''' - cd expenses_manager - . venv/bin/activate - venv/bin/pytest --cov - ''' + 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 \ + --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 { - echo 'Pipeline finished.' - } - failure { - echo 'Tests failed.' + failure { notifyTelegram("❌ FALLO") } + fixed { notifyTelegram("✅ Recuperado") } + aborted { notifyTelegram("⚠️ Abortado") } + } +} + +// 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) { + 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