From 8cae6533d51ac746012df0ed6fb454a42afa70e5 Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Mon, 29 Jun 2026 11:48:04 +0200 Subject: [PATCH] Replace Singularity with Docker; add production deployment setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 17 +++++++ .gitlab-ci.yml | 110 ++++++++++++++++++++++++++++--------------- Dockerfile | 20 ++++++++ Singularity.def | 20 -------- deploy/entrypoint.sh | 26 ++++++++++ deploy/nginx.conf | 40 ++++++++++++++++ docker-compose.yml | 42 +++++++++++++++++ 7 files changed, 217 insertions(+), 58 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 Singularity.def create mode 100755 deploy/entrypoint.sh create mode 100644 deploy/nginx.conf create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..55ce070 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 226c84c..76970ed 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e283d23 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Singularity.def b/Singularity.def deleted file mode 100644 index b914821..0000000 --- a/Singularity.def +++ /dev/null @@ -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 "$@" diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100755 index 0000000..0fdc5a7 --- /dev/null +++ b/deploy/entrypoint.sh @@ -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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..4548a33 --- /dev/null +++ b/deploy/nginx.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b40e7f --- /dev/null +++ b/docker-compose.yml @@ -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: