diff --git a/expenses_manager/Jenkinsfile b/expenses_manager/Jenkinsfile index fe02f7d..4aa6fb1 100644 --- a/expenses_manager/Jenkinsfile +++ b/expenses_manager/Jenkinsfile @@ -2,18 +2,30 @@ pipeline { agent any environment { - // settings.py demands SECRET_KEY unless DEBUG is exactly "True". - // A throwaway value lets CI run with DEBUG unset — that is, False, as - // in production — instead of falling back to the insecure dev key. + // DEBUG=True keeps settings.py from enabling SECURE_SSL_REDIRECT, which + // makes Django's test client receive a 301 on every request. DEBUG = 'True' SECRET_KEY = 'ci-only-value-never-used-in-production' DJANGO_SETTINGS_MODULE = 'expenses_manager.settings' PYTHONUNBUFFERED = '1' + + // Jenkins has no Docker daemon, so images are built on the NAS over SSH. + // Absolute paths are required: the remote non-interactive PATH is + // /usr/bin:/bin:/usr/sbin:/sbin and does not include /usr/local/bin. + NAS_HOST = '192.168.1.140' + NAS_USER = 'jenkins-ci' + NAS_DOCKER = '/usr/local/bin/docker' + CI_DIR = '/volume1/docker/ci/expenses_manager' + SSH_OPTS = '-o StrictHostKeyChecking=accept-new' + + // 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 { buildDiscarder(logRotator(numToKeepStr: '30')) - timeout(time: 20, unit: 'MINUTES') + timeout(time: 30, unit: 'MINUTES') } stages { @@ -48,13 +60,60 @@ pipeline { ''' } } + + 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 on the NAS: 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. + // Running Django's own checks inside the image proves it can actually + // start: settings load, middleware resolves, static files are collected. + 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" + ''' + } + } + } } 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 — as happened for months in 2026. + // rather than pass quietly. junit allowEmptyResults: false, testResults: 'expenses_manager/junit.xml' } failure {