363 lines
18 KiB
Groovy
363 lines
18 KiB
Groovy
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 {
|
|
// Everything Docker-related happens on the NAS over SSH: Jenkins has no
|
|
// daemon of its own. 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 {
|
|
|
|
// Nested stages share one agent allocation.
|
|
stage('CI') {
|
|
agent any
|
|
options { timeout(time: 40, unit: 'MINUTES') }
|
|
stages {
|
|
|
|
stage('Build images') {
|
|
steps {
|
|
sshagent(credentials: ['nas-ssh']) {
|
|
sh '''
|
|
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
|
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-')
|
|
|
|
# Ship the workspace rather than cloning again on the
|
|
# NAS: what gets built is then provably the same code
|
|
# this build checked out.
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"rm -rf ${CI_DIR} && mkdir -p ${CI_DIR}"
|
|
|
|
tar -czf - --exclude=./.git . \
|
|
| ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"tar -xzf - -C ${CI_DIR}"
|
|
|
|
# The test image is the runtime image plus test
|
|
# dependencies, so the second build reuses every layer
|
|
# of the first.
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"cd ${CI_DIR} && sudo ${NAS_DOCKER} build \
|
|
--target runtime \
|
|
-t ${IMAGE}:${SHA} -t ${IMAGE}:${TAG} ."
|
|
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"cd ${CI_DIR} && sudo ${NAS_DOCKER} build \
|
|
--target test \
|
|
-t ${IMAGE}-test:${SHA} ."
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
|
|
// Catches models changed without generating the migration. This
|
|
// never touches a database, so SQLite is enough here.
|
|
stage('Check migrations') {
|
|
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=ci-only-value \
|
|
-e DEBUG=False \
|
|
-e ALLOWED_HOSTS=localhost,testserver \
|
|
-e DJANGO_SETTINGS_MODULE=expenses_manager.settings_test \
|
|
-e DB_ENGINE=sqlite3 \
|
|
${IMAGE}-test:${SHA} \
|
|
python manage.py makemigrations --check --dry-run"
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Run tests') {
|
|
steps {
|
|
sshagent(credentials: ['nas-ssh']) {
|
|
// Tests run against PostgreSQL, the engine production
|
|
// uses. SQLite is only a local-development convenience
|
|
// and the two have already differed once (default
|
|
// ordering), so a green SQLite run proves little.
|
|
sh '''
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"sudo ${NAS_DOCKER} network create ci-net-${BUILD_NUMBER} \
|
|
&& sudo ${NAS_DOCKER} run -d \
|
|
--name ci-db-${BUILD_NUMBER} \
|
|
--network ci-net-${BUILD_NUMBER} \
|
|
-e POSTGRES_DB=ci \
|
|
-e POSTGRES_USER=ci \
|
|
-e POSTGRES_PASSWORD=ci-only-password \
|
|
postgres:15"
|
|
'''
|
|
|
|
sh '''
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
|
|
sudo ${NAS_DOCKER} exec ci-db-${BUILD_NUMBER} \
|
|
pg_isready -U ci -d ci > /dev/null 2>&1 && exit 0; \
|
|
sleep 2; \
|
|
done; \
|
|
echo 'Postgres no arranco a tiempo'; exit 1"
|
|
'''
|
|
|
|
sh '''
|
|
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"sudo ${NAS_DOCKER} run --rm \
|
|
--network ci-net-${BUILD_NUMBER} \
|
|
-e SECRET_KEY=ci-only-value \
|
|
-e DEBUG=False \
|
|
-e ALLOWED_HOSTS=localhost,testserver \
|
|
-e DJANGO_SETTINGS_MODULE=expenses_manager.settings_test \
|
|
-e DB_ENGINE=postgresql \
|
|
-e DB_NAME=ci \
|
|
-e DB_USER=ci \
|
|
-e DB_PASSWORD=ci-only-password \
|
|
-e DB_HOST=ci-db-${BUILD_NUMBER} \
|
|
-e DB_PORT=5432 \
|
|
${IMAGE}-test:${SHA} \
|
|
sh -c 'pytest --cov --junitxml=/tmp/junit.xml >&2; \
|
|
rc=\\$?; cat /tmp/junit.xml; exit \\$rc'" \
|
|
> 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: 'junit.xml'
|
|
|
|
// The throwaway database goes away whatever happened, so a
|
|
// failed build does not leave containers behind.
|
|
sshagent(credentials: ['nas-ssh']) {
|
|
sh '''
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"sudo ${NAS_DOCKER} rm -f ci-db-${BUILD_NUMBER} > /dev/null 2>&1; \
|
|
sudo ${NAS_DOCKER} network rm ci-net-${BUILD_NUMBER} > /dev/null 2>&1; \
|
|
exit 0"
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 migrations directory without __init__.py is a namespace
|
|
// package, and Django deliberately ignores those: the app
|
|
// then appears to have no migrations at all, migrate --check
|
|
// always reports nothing pending, and a real migration would
|
|
// silently never be applied.
|
|
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 showmigrations expenses" \
|
|
| grep -q '0001_initial'
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Prune old images') {
|
|
steps {
|
|
sshagent(credentials: ['nas-ssh']) {
|
|
// docker images lists newest first, so anything past the
|
|
// fifth is an older build. Branch tags are kept because
|
|
// they always point at a recent image, and the image the
|
|
// running container uses cannot be removed anyway.
|
|
sh '''
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"for repo in ${IMAGE} ${IMAGE}-test; do \
|
|
sudo ${NAS_DOCKER} images \\$repo --format '{{.Repository}}:{{.Tag}}' \
|
|
| grep -v ':dev' | grep -v ':main' | grep -v '<none>' \
|
|
| tail -n +6 \
|
|
| while read img; do \
|
|
sudo ${NAS_DOCKER} rmi \\$img > /dev/null 2>&1; \
|
|
done; \
|
|
done; \
|
|
sudo ${NAS_DOCKER} image prune -f > /dev/null; \
|
|
exit 0"
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
notifyTelegram("⏸️ Esperando aprobacion",
|
|
"Hay migraciones pendientes. Entra desde el enlace para revisar el plan y aprobar el despliegue. Si no respondes en 2 horas, el despliegue se cancela.")
|
|
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.
|
|
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 {
|
|
// The Host header must match ALLOWED_HOSTS or Django answers 400
|
|
// regardless of health. A 301 is expected: SECURE_SSL_REDIRECT
|
|
// sends plain HTTP to HTTPS, and reaching that redirect already
|
|
// proves the app processed the request.
|
|
def code = sh(returnStdout: true, script: '''
|
|
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
|
"sleep 10 && curl -s -o /dev/null -w '%{http_code}' \
|
|
-H 'Host: finanzas.kuijper.es' \
|
|
http://127.0.0.1:8001/"
|
|
''').trim()
|
|
|
|
echo "Respuesta HTTP: ${code}"
|
|
if (!(code in ['200', '301', '302'])) {
|
|
error("La aplicacion no responde correctamente (HTTP ${code})")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
post {
|
|
success {
|
|
notifyTelegram("🚀 Desplegado", "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
post {
|
|
failure { notifyTelegram("❌ FALLO", "") }
|
|
fixed { notifyTelegram("✅ Recuperado", "") }
|
|
aborted { notifyTelegram("⚠️ Abortado", "") }
|
|
}
|
|
}
|
|
|
|
// Sends a one-line build summary to Telegram, with an optional extra paragraph.
|
|
// The node block is required because the pipeline declares "agent none", so post
|
|
// actions have no workspace of their own. Both parameters are mandatory to avoid
|
|
// Groovy default arguments, which Jenkins' CPS interpreter handles poorly.
|
|
void notifyTelegram(String status, String extra) {
|
|
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}
|
|
${extra}" > /dev/null
|
|
"""
|
|
}
|
|
}
|
|
} |