Compare commits
No commits in common. "a35ccb2fd9b5cb7fffdac8a1379f3de4b2939684" and "dbe993a814106d9a03a8753f8c65b5c97b79b1d0" have entirely different histories.
a35ccb2fd9
...
dbe993a814
@ -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
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@ -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 \
|
||||||
@ -26,11 +29,3 @@ 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
|
|
||||||
103
expenses_manager/Jenkinsfile
vendored
103
expenses_manager/Jenkinsfile
vendored
@ -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 '''
|
||||||
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
cd expenses_manager
|
||||||
TAG=$(echo "${BRANCH_NAME}" | tr '/' '-')
|
python --version
|
||||||
|
python -m venv venv
|
||||||
# Ship the workspace rather than cloning again on the
|
venv/bin/pip install --upgrade pip
|
||||||
# NAS: what gets built is then provably the same code
|
venv/bin/pip install -r requirements.txt
|
||||||
# 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 '''
|
||||||
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
cd expenses_manager
|
||||||
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
venv/bin/python manage.py makemigrations --check --dry-run
|
||||||
"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']) {
|
|
||||||
// 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, which
|
|
||||||
// jenkins-ci could not read.
|
|
||||||
sh '''
|
sh '''
|
||||||
SHA=$(echo "${GIT_COMMIT}" | cut -c1-7)
|
cd expenses_manager
|
||||||
ssh ${SSH_OPTS} ${NAS_USER}@${NAS_HOST} \
|
venv/bin/pytest --cov --junitxml=junit.xml
|
||||||
"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']) {
|
||||||
@ -136,10 +121,10 @@ pipeline {
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
// 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} \
|
||||||
|
|||||||
@ -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
|
|
||||||
Loading…
Reference in New Issue
Block a user