dev #39

Merged
jkuijperm merged 7 commits from dev into main 2026-10-01 10:14:44 +00:00
2 changed files with 97 additions and 27 deletions
Showing only changes of commit 9d17a45910 - Show all commits

View File

@ -5,32 +5,33 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
# Dependencies first: this layer is cached and only rebuilt when
# requirements.txt changes, not on every code change.
COPY expenses_manager/requirements.txt . COPY expenses_manager/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy the Django project itself, not the repository root, so /app is the
# directory holding manage.py.
COPY expenses_manager/ . COPY expenses_manager/ .
# Collect static files at build time so the running container never needs to
# write to its own filesystem. SECRET_KEY is required by settings.py when
# DEBUG is False but collectstatic never uses it, so a throwaway value is
# enough — the real one comes from the environment at runtime.
RUN SECRET_KEY=build-time-placeholder \ RUN SECRET_KEY=build-time-placeholder \
DEBUG=False \ DEBUG=False \
ALLOWED_HOSTS=localhost \ ALLOWED_HOSTS=localhost \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Run as an unprivileged account. /app must belong to it: gunicorn writes its
# control socket there, and pytest its cache in the test image.
RUN useradd --create-home --uid 1000 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["gunicorn", "expenses_manager.wsgi:application", "--bind", "0.0.0.0:8000"] CMD ["gunicorn", "expenses_manager.wsgi:application", "--bind", "0.0.0.0:8000"]
# The production image plus the test-only dependencies. Tests then run on # The production image plus the test-only dependencies.
# exactly the Python version and packages production uses; the virtualenv this
# replaces ran on Python 3.14 while production runs 3.11.
FROM runtime AS test FROM runtime AS test
# pip writes to the system site-packages, so the install runs as root and the
# image then drops back to the unprivileged account.
USER root
RUN pip install --no-cache-dir -r requirements-dev.txt RUN pip install --no-cache-dir -r requirements-dev.txt
USER appuser

View File

@ -29,7 +29,7 @@ pipeline {
// Nested stages share one agent allocation. // Nested stages share one agent allocation.
stage('CI') { stage('CI') {
agent any agent any
options { timeout(time: 30, unit: 'MINUTES') } options { timeout(time: 40, unit: 'MINUTES') }
stages { stages {
stage('Build images') { stage('Build images') {
@ -66,7 +66,8 @@ pipeline {
} }
} }
// Catches models changed without generating the migration. // Catches models changed without generating the migration. This
// never touches a database, so SQLite is enough here.
stage('Check migrations') { stage('Check migrations') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sshagent(credentials: ['nas-ssh']) {
@ -89,18 +90,47 @@ pipeline {
stage('Run tests') { stage('Run tests') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sshagent(credentials: ['nas-ssh']) {
// pytest output goes to stderr so it shows up in this log, // Tests run against PostgreSQL, the engine production
// while stdout carries only the JUnit XML. That avoids // uses. SQLite is only a local-development convenience
// copying a root-owned file back from the NAS. // 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 ''' 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 \
--network ci-net-${BUILD_NUMBER} \
-e SECRET_KEY=ci-only-value \ -e SECRET_KEY=ci-only-value \
-e DEBUG=False \ -e DEBUG=False \
-e ALLOWED_HOSTS=localhost,testserver \ -e ALLOWED_HOSTS=localhost,testserver \
-e DJANGO_SETTINGS_MODULE=expenses_manager.settings_test \ -e DJANGO_SETTINGS_MODULE=expenses_manager.settings_test \
-e DB_ENGINE=sqlite3 \ -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} \ ${IMAGE}-test:${SHA} \
sh -c 'pytest --cov --junitxml=/tmp/junit.xml >&2; \ sh -c 'pytest --cov --junitxml=/tmp/junit.xml >&2; \
rc=\\$?; cat /tmp/junit.xml; exit \\$rc'" \ rc=\\$?; cat /tmp/junit.xml; exit \\$rc'" \
@ -114,6 +144,17 @@ pipeline {
// pytest died before collecting anything produces no XML, // pytest died before collecting anything produces no XML,
// and that must fail rather than pass quietly. // and that must fail rather than pass quietly.
junit allowEmptyResults: false, testResults: 'junit.xml' 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"
'''
}
} }
} }
} }
@ -151,6 +192,30 @@ pipeline {
} }
} }
} }
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"
'''
}
}
}
} }
} }
@ -201,6 +266,8 @@ pipeline {
} }
} }
steps { 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') { timeout(time: 2, unit: 'HOURS') {
input message: 'Hay migraciones pendientes. Revisa el plan en el log del stage anterior.', input message: 'Hay migraciones pendientes. Revisa el plan en el log del stage anterior.',
ok: 'Migrar y desplegar' ok: 'Migrar y desplegar'
@ -260,23 +327,24 @@ pipeline {
} }
post { post {
success { success {
notifyTelegram("🚀 Desplegado") notifyTelegram("🚀 Desplegado", "")
} }
} }
} }
} }
post { post {
failure { notifyTelegram("❌ FALLO") } failure { notifyTelegram("❌ FALLO", "") }
fixed { notifyTelegram("✅ Recuperado") } fixed { notifyTelegram("✅ Recuperado", "") }
aborted { notifyTelegram("⚠️ Abortado") } aborted { notifyTelegram("⚠️ Abortado", "") }
} }
} }
// Sends a one-line build summary to Telegram. The node block is required because // Sends a one-line build summary to Telegram, with an optional extra paragraph.
// the pipeline declares "agent none", so post actions have no workspace of their // The node block is required because the pipeline declares "agent none", so post
// own. Credentials come from the Jenkins store, never from the repository. // actions have no workspace of their own. Both parameters are mandatory to avoid
void notifyTelegram(String status) { // Groovy default arguments, which Jenkins' CPS interpreter handles poorly.
void notifyTelegram(String status, String extra) {
node { node {
withCredentials([ withCredentials([
string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'), string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'),
@ -287,7 +355,8 @@ void notifyTelegram(String status) {
--data-urlencode "chat_id=\${TG_CHAT}" \ --data-urlencode "chat_id=\${TG_CHAT}" \
--data-urlencode "text=${status} ${env.JOB_NAME} #${env.BUILD_NUMBER} --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}
${extra}" > /dev/null
""" """
} }
} }