Replace Singularity with Docker; add production deployment setup

- Remove Singularity.def; add Dockerfile (python:3.11-slim + ImageMagick,
  non-root user, installs gallery package with dev extras)
- Add .dockerignore to keep image lean
- Rewrite .gitlab-ci.yml: build→test→publish stages using Docker-in-Docker;
  push per-commit SHA tag and promote to :latest on main
- Add docker-compose.yml: gallery-generator + nginx services sharing a
  named volume; configurable GENERATE_INTERVAL env var
- Add deploy/nginx.conf: gzip, security headers, correct caching policy
  (immutable for assets, no-store for HTML)
- Add deploy/entrypoint.sh: runs gallery generate on startup then loops
  on GENERATE_INTERVAL; exits cleanly when interval is 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:48:04 +02:00
parent 8e3b928bb0
commit 8cae6533d5
7 changed files with 217 additions and 58 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.venv
*.sif
*.ipynb
__pycache__
*.pyc
*.pyo
.pytest_cache
build
*.egg-info
.claude
config.yaml
deploy/
docs/
README.md
CLAUDE.md
uv.lock
+72 -38
View File
@@ -1,59 +1,70 @@
# GitLab CI/CD Pipeline for Gallery Generator
# Builds Apptainer container and runs tests inside it
# GitLab CI/CD Pipeline for ETPlot
# Builds a Docker image and runs tests inside it.
stages:
- build
- test
- publish
variables:
CONTAINER_IMAGE: "gallery-generator.sif"
APPTAINER_CACHE_DIR: "$CI_PROJECT_DIR/.apptainer-cache"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
# Cache to speed up builds
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .apptainer-cache/
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
# Build the Apptainer container
build:container:
build:image:
stage: build
tags:
- apptainer
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- echo "Building Apptainer container..."
- apptainer --version
- apptainer build --fakeroot $CONTAINER_IMAGE Singularity.def
- ls -lh $CONTAINER_IMAGE
artifacts:
paths:
- $CONTAINER_IMAGE
expire_in: 1 hour
- docker build --pull -t "$IMAGE_TAG" .
- docker push "$IMAGE_TAG"
rules:
- if: $CI_COMMIT_BRANCH
# Run tests inside the container
test:pytest:
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
.test_base:
stage: test
tags:
- apptainer
dependencies:
- build:container
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker pull "$IMAGE_TAG"
needs:
- build:image
test:pytest:
extends: .test_base
script:
- echo "Running pytest inside container..."
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ -v
- docker run --rm -w /app "$IMAGE_TAG" python -m pytest tests/ -v
artifacts:
when: always
expire_in: 30 days
# Run tests with coverage
test:coverage:
stage: test
tags:
- apptainer
dependencies:
- build:container
extends: .test_base
script:
- echo "Running coverage analysis inside container..."
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ --cov=/src --cov-report=xml --cov-report=term
- apptainer exec $CONTAINER_IMAGE cat /src/coverage.xml > coverage.xml || echo "No coverage.xml found"
- |
docker run --rm -w /app \
-v "$CI_PROJECT_DIR:/artifacts" \
"$IMAGE_TAG" \
python -m pytest tests/ \
--cov=gallery \
--cov-report=xml:/artifacts/coverage.xml \
--cov-report=term
coverage: '/TOTAL.*\s+(\d+%)$/'
artifacts:
reports:
@@ -63,3 +74,26 @@ test:coverage:
paths:
- coverage.xml
expire_in: 30 days
# ---------------------------------------------------------------------------
# Publish latest tag on main
# ---------------------------------------------------------------------------
publish:latest:
stage: publish
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- docker pull "$IMAGE_TAG"
- docker tag "$IMAGE_TAG" "$IMAGE_LATEST"
- docker push "$IMAGE_LATEST"
needs:
- test:pytest
- test:coverage
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.11-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends imagemagick \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml .
COPY gallery/ gallery/
COPY tests/ tests/
COPY deploy/entrypoint.sh deploy/entrypoint.sh
RUN pip install --no-cache-dir ".[dev]"
RUN useradd -r -s /bin/false gallery
USER gallery
ENTRYPOINT ["gallery"]
CMD ["generate"]
-20
View File
@@ -1,20 +0,0 @@
Bootstrap: docker
From: python:3.11-slim
%post
apt-get update
apt-get install -y --no-install-recommends imagemagick
rm -rf /var/lib/apt/lists/*
pip install --no-cache-dir jinja2 pyyaml coverage pytest pytest-cov
mkdir -p /src
%files
. /src
%environment
export PYTHONPATH=/src
%runscript
cd /src
exec python3 generate_gallery.py "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -e
CONFIG_FILE="${CONFIG_FILE:-/config/config.yaml}"
GENERATE_INTERVAL="${GENERATE_INTERVAL:-300}" # seconds between regenerations; 0 = run once and exit
if [ ! -f "$CONFIG_FILE" ]; then
echo "ERROR: config file not found at $CONFIG_FILE" >&2
exit 1
fi
run_generate() {
echo "[$(date -u +%FT%TZ)] Running gallery generate..."
gallery --config "$CONFIG_FILE" generate --verbose
}
run_generate
if [ "$GENERATE_INTERVAL" -eq 0 ]; then
exit 0
fi
while true; do
sleep "$GENERATE_INTERVAL"
run_generate
done
+40
View File
@@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /var/www/gallery;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Static assets: cache aggressively
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
# HTML: no cache so regenerated galleries are picked up immediately
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ $uri/index.html =404;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}
+42
View File
@@ -0,0 +1,42 @@
services:
generator:
build: .
image: etplot-gallery:latest
entrypoint: ["/app/deploy/entrypoint.sh"]
environment:
CONFIG_FILE: /config/config.yaml
# How often to regenerate the gallery (seconds). Set to 0 to run once and exit.
GENERATE_INTERVAL: "300"
volumes:
- ./config.yaml:/config/config.yaml:ro
# Mount your plot source directories here, matching the paths in config.yaml.
# Example:
# - /path/to/plots:/plots:ro
- gallery_output:/var/www/gallery
restart: unless-stopped
healthcheck:
test: ["CMD", "gallery", "--help"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
web:
image: nginx:1.27-alpine
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- gallery_output:/var/www/gallery:ro
depends_on:
- generator
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
gallery_output: