Compare commits

..

No commits in common. "a35ccb2fd9b5cb7fffdac8a1379f3de4b2939684" and "dbe993a814106d9a03a8753f8c65b5c97b79b1d0" have entirely different histories.

4 changed files with 73 additions and 102 deletions

View File

@ -26,6 +26,8 @@
# CI and test configuration, not needed at runtime. # CI and test configuration, not needed at runtime.
**/Jenkinsfile **/Jenkinsfile
**/pytest.ini
**/tests/
# Documentation and planning, not needed at runtime. # Documentation and planning, not needed at runtime.
**/*.md **/*.md

View File

@ -1,5 +1,6 @@
FROM python:3.11-slim AS runtime FROM python:3.11-slim
# Fail fast and log straight to stdout, so `docker-compose logs` is useful.
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
@ -10,14 +11,16 @@ WORKDIR /app
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 # Copy the Django project itself, not the repository root, so that /app is
# directory holding manage.py. # the directory holding manage.py. The previous version copied the repo root
# into /app/expenses_manager, which left manage.py and wsgi.py one level
# deeper than the gunicorn command expected.
COPY expenses_manager/ . COPY expenses_manager/ .
# Collect static files at build time so the running container never needs to # 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 # 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 # DEBUG is False, but collectstatic never uses it, so a throwaway value is
# enough — the real one comes from the environment at runtime. # 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 \
@ -25,12 +28,4 @@ RUN SECRET_KEY=build-time-placeholder \
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
# 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
RUN pip install --no-cache-dir -r requirements-dev.txt

View File

@ -38,89 +38,74 @@ pipeline {
options { timeout(time: 30, unit: 'MINUTES') } options { timeout(time: 30, unit: 'MINUTES') }
stages { stages {
stage('Build images') { stage('Setup Python') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sh '''
sh ''' cd expenses_manager
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) python --version
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-') python -m venv venv
venv/bin/pip install --upgrade pip
# Ship the workspace rather than cloning again on the venv/bin/pip install -r requirements.txt
# 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. // Catches models changed without generating the migration.
stage('Check migrations') { stage('Check migrations') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sh '''
sh ''' cd expenses_manager
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7) venv/bin/python manage.py makemigrations --check --dry-run
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \ '''
"sudo ${NAS_DOCKER} run --rm \
-e SECRET_KEY=ci-only-value \
-e DEBUG=True \
-e DB_ENGINE=sqlite3 \
${IMAGE}-test:${SHA} \
python manage.py makemigrations --check --dry-run"
'''
}
} }
} }
stage('Run tests') { stage('Run tests') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sh '''
// pytest output goes to stderr so it shows up in this log, cd expenses_manager
// while stdout carries only the JUnit XML. That avoids venv/bin/pytest --cov --junitxml=junit.xml
// copying a root-owned file back from the NAS, which '''
// jenkins-ci could not read.
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=True \
-e DB_ENGINE=sqlite3 \
${IMAGE}-test:${SHA} \
sh -c 'pytest --cov --junitxml=/tmp/junit.xml >&2; \
rc=\\$?; cat /tmp/junit.xml; exit \\$rc'" \
> junit.xml
'''
}
} }
post { post {
always { always {
// allowEmptyResults: false is deliberate. A build where // allowEmptyResults: false is deliberate. A build where
// 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: 'expenses_manager/junit.xml'
} }
} }
} }
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: 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. Django's own checks prove the image can actually start.
stage('Smoke test image') { stage('Smoke test image') {
steps { steps {
sshagent(credentials: ['nas-ssh']) { sshagent(credentials: ['nas-ssh']) {
@ -135,22 +120,22 @@ pipeline {
${IMAGE}:${SHA} python manage.py check" ${IMAGE}:${SHA} python manage.py check"
''' '''
// A migrations directory without __init__.py is a namespace // A migrations directory without __init__.py is a namespace
// package, and Django deliberately ignores those: the app // package, and Django deliberately ignores those: the app then
// then appears to have no migrations at all, migrate --check // appears to have no migrations at all, migrate --check always
// always reports nothing pending, and a real migration would // reports nothing pending, and a real migration would silently
// silently never be applied. // never be applied. This is what .gitignore line 6 caused.
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 \
-e DEBUG=False \ -e DEBUG=False \
-e ALLOWED_HOSTS=localhost \ -e ALLOWED_HOSTS=localhost \
-e DB_ENGINE=sqlite3 \ -e DB_ENGINE=sqlite3 \
${IMAGE}:${SHA} python manage.py showmigrations expenses" \ ${IMAGE}:${SHA} python manage.py showmigrations expenses" \
| grep -q '0001_initial' | grep -q '0001_initial'
''' '''
} }
} }
} }

View File

@ -1,11 +0,0 @@
-r requirements.txt
colorama==0.4.6
coverage==7.13.1
iniconfig==2.3.0
pluggy==1.6.0
Pygments==2.19.2
pytest==9.0.2
pytest-cov>=4.0
pytest-django==4.11.1
toml==0.10.2