diff --git a/Dockerfile b/Dockerfile index 82555c2..0010002 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,32 +5,33 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ 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 . 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/ . -# 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 \ DEBUG=False \ ALLOWED_HOSTS=localhost \ 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 CMD ["gunicorn", "expenses_manager.wsgi:application", "--bind", "0.0.0.0:8000"] -# The production image plus the test-only dependencies. Tests then run on -# exactly the Python version and packages production uses; the virtualenv this -# replaces ran on Python 3.14 while production runs 3.11. +# The production image plus the test-only dependencies. FROM runtime AS test -RUN pip install --no-cache-dir -r requirements-dev.txt \ No newline at end of file +# 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 +USER appuser \ No newline at end of file diff --git a/expenses_manager/Jenkinsfile b/expenses_manager/Jenkinsfile index 80f8303..2596741 100644 --- a/expenses_manager/Jenkinsfile +++ b/expenses_manager/Jenkinsfile @@ -29,7 +29,7 @@ pipeline { // Nested stages share one agent allocation. stage('CI') { agent any - options { timeout(time: 30, unit: 'MINUTES') } + options { timeout(time: 40, unit: 'MINUTES') } stages { 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') { steps { sshagent(credentials: ['nas-ssh']) { @@ -89,18 +90,47 @@ pipeline { stage('Run tests') { steps { sshagent(credentials: ['nas-ssh']) { - // pytest output goes to stderr so it shows up in this log, - // while stdout carries only the JUnit XML. That avoids - // copying a root-owned file back from the NAS. + // 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=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} \ sh -c 'pytest --cov --junitxml=/tmp/junit.xml >&2; \ rc=\\$?; cat /tmp/junit.xml; exit \\$rc'" \ @@ -114,6 +144,17 @@ pipeline { // 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" + ''' + } } } } @@ -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 '' \ + | 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 { + 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' @@ -260,23 +327,24 @@ pipeline { } post { success { - notifyTelegram("πŸš€ Desplegado") + notifyTelegram("πŸš€ Desplegado", "") } } } } post { - failure { notifyTelegram("❌ FALLO") } - fixed { notifyTelegram("βœ… Recuperado") } - aborted { notifyTelegram("⚠️ Abortado") } + 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) { +// 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'), @@ -287,7 +355,8 @@ void notifyTelegram(String status) { --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 +${env.BUILD_URL} +${extra}" > /dev/null """ } }