Updated Jenkinsfile
This commit is contained in:
parent
80f56245dd
commit
9df4988cc7
189
expenses_manager/Jenkinsfile
vendored
189
expenses_manager/Jenkinsfile
vendored
@ -1,34 +1,43 @@
|
|||||||
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 {
|
||||||
|
|
||||||
|
// 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') {
|
stage('Setup Python') {
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
@ -41,8 +50,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catches models changed without generating the migration. Exactly the
|
// Catches models changed without generating the migration.
|
||||||
// failure mode that reached production as a 500 on 2026-09-29.
|
|
||||||
stage('Check migrations') {
|
stage('Check migrations') {
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
@ -59,6 +67,15 @@ pipeline {
|
|||||||
venv/bin/pytest --cov --junitxml=junit.xml
|
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') {
|
stage('Build image') {
|
||||||
@ -68,9 +85,9 @@ pipeline {
|
|||||||
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
||||||
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-')
|
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-')
|
||||||
|
|
||||||
# Ship the workspace that was just tested, rather than
|
# Ship the workspace that was just tested rather than
|
||||||
# cloning again on the NAS: what gets built is then
|
# cloning again: what gets built is then provably the
|
||||||
# provably the same code the tests ran against.
|
# same code the tests ran against.
|
||||||
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
||||||
"rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}"
|
"rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}"
|
||||||
|
|
||||||
@ -87,15 +104,13 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A successful build only proves the Dockerfile is syntactically sound.
|
// A successful build only proves the Dockerfile is syntactically
|
||||||
// Running Django's own checks inside the image proves it can actually
|
// sound. Django's own checks prove the image can actually start.
|
||||||
// start: settings load, middleware resolves, static files are collected.
|
|
||||||
stage('Smoke test image') {
|
stage('Smoke test image') {
|
||||||
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)
|
||||||
|
|
||||||
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 \
|
-e SECRET_KEY=smoke-test-only \
|
||||||
@ -108,26 +123,131 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 \
|
||||||
|
--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 {
|
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) {
|
||||||
|
node {
|
||||||
withCredentials([
|
withCredentials([
|
||||||
string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'),
|
string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'),
|
||||||
string(credentialsId: 'telegram-chat-id', variable: 'TG_CHAT')
|
string(credentialsId: 'telegram-chat-id', variable: 'TG_CHAT')
|
||||||
@ -140,4 +260,5 @@ Rama: ${env.BRANCH_NAME ?: 'main'}
|
|||||||
${env.BUILD_URL}" > /dev/null
|
${env.BUILD_URL}" > /dev/null
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user