From ce5a8724f51527e6b0f5552e0794ca8491af419a Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 28 Sep 2026 15:03:40 -0400 Subject: [PATCH] Run the server in Docker for the new lab server - Dockerfile: Python 3.11 on Debian 12 (as on the old server), with the system libraries WeasyPrint and RDKit need and DejaVu fonts for labels. Runs gunicorn as uid 1000 with one worker and a 600 s timeout (matching the timer client's wait). Lab timezone, so "Opened" dates are local. - constraints.txt pins the package versions the tests were run against. - deploy/compose.yaml builds from the checkout and mounts config.yaml and secrets.yaml (never baked into the image). Joins lab-net for the edge proxy and listens on 127.0.0.1:5001 for the local timer client. Refuses to start if either file is missing instead of creating empty folders. - CI builds the image, runs the offline tests inside it, and smoke-tests /ping and a label PDF (checks the text renders in DejaVu). - client/install.sh accepts ELN_SERVER_URL to point the timers at the server without editing the unit files. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 21 ++++++++++++ .github/workflows/docker-image.yml | 52 ++++++++++++++++++++++++++++++ Dockerfile | 41 +++++++++++++++++++++++ README.md | 32 ++++++++++++++++++ client/install.sh | 8 +++++ constraints.txt | 46 ++++++++++++++++++++++++++ deploy/compose.yaml | 44 +++++++++++++++++++++++++ 7 files changed, 244 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-image.yml create mode 100644 Dockerfile create mode 100644 constraints.txt create mode 100644 deploy/compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f02deb6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git/ +.github/ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ + +# secrets and per-server settings are mounted at run time, never baked into the image +secrets.yaml +config.yaml +api_key +slack_bot_token + +# generated output +static/print.pdf + +# not needed at run time +tests/ +client/ +deploy/ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..297b155 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,52 @@ +name: Docker image + +# Proves on every pull request and push to main that the image builds, the +# offline tests pass inside it, and it serves requests. Nothing is published: +# the lab server builds its own image from its checkout (deploy/compose.yaml). +on: + push: + branches: [main] + pull_request: + workflow_dispatch: # adds a "Run workflow" button in the Actions tab + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + run: docker build -t eln-server-ci . + + - name: Dummy config for the tests and smoke test (no real ELN or Slack) + run: | + cp config-ex.yaml config.yaml + sed -i 's|^eln_url:.*|eln_url: https://eln.invalid/api/v2|; s|^eln_web_url:.*|eln_web_url: https://eln.invalid|; s|^slack_enabled:.*|slack_enabled: false|' config.yaml + cp secrets.example.yaml secrets.yaml + + - name: Offline tests inside the image + run: | + docker run --rm --user root -e PYTHONDONTWRITEBYTECODE=1 -v "$PWD":/src -w /src eln-server-ci \ + sh -c 'pip install -q pytest && python -m pytest -m "not live" -p no:cacheprovider' + + - name: Smoke test the image + run: | + docker run -d --name eln -p 5000:5000 \ + -v "$PWD/config.yaml":/app/config.yaml -v "$PWD/secrets.yaml":/app/secrets.yaml:ro \ + eln-server-ci + for i in $(seq 30); do curl -fsS http://127.0.0.1:5000/ping && break || sleep 1; done + curl -fsS -o /dev/null http://127.0.0.1:5000/ + # label PDFs need Pango and fonts in the image: check one renders its text in DejaVu + curl -fsS -X POST -H 'Content-Type: application/json' -o label.pdf \ + -d '{"Title":"CI","Text":"smoke test","Icon":"None","QRContentType":"Resource","QRContent":1,"Height":18}' \ + http://127.0.0.1:5000/create_label + docker run --rm -v "$PWD/label.pdf":/tmp/label.pdf:ro eln-server-ci python -c " + from pypdf import PdfReader + page = PdfReader('/tmp/label.pdf').pages[0] + fonts = [str(f.get_object()['/BaseFont']) for f in page['/Resources']['/Font'].values()] + print(fonts) + assert 'smoke test' in page.extract_text() + assert any('DejaVu' in f for f in fonts) + " + docker logs eln + docker rm -f eln diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5032d63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# eln_server (Flask + gunicorn). Build context: the repository root. +# config.yaml and secrets.yaml are not baked in: mount them at run time (see deploy/compose.yaml). +FROM python:3.11-slim-bookworm + +# system libraries the Python packages load at run time: +# WeasyPrint (label PDFs): Pango + DejaVu fonts (labels ask for Courier New -> DejaVu Sans Mono) +# RDKit (structure images): libXrender, libXext +# tzdata: "Opened" dates are stamped in lab time (TZ below) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 fonts-dejavu-core libxrender1 libxext6 tzdata \ + && rm -rf /var/lib/apt/lists/* + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TZ=America/New_York + +WORKDIR /app + +# dependencies first: this layer is cached and only rebuilt when they change. +# constraints.txt pins the versions the test suite was run against. +COPY requirements.txt constraints.txt ./ +RUN pip install -r requirements.txt -c constraints.txt + +# then the application code +COPY . . + +# run as a non-root user with uid 1000, so the mounted config.yaml/secrets.yaml can +# stay owned by the server's admin user. The app writes label PDFs to static/; the home +# folder holds gunicorn's control socket and the font cache. +RUN useradd --uid 1000 --create-home eln && chown eln:eln /app/static +USER eln +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/ping', timeout=4).status==200 else 1)" + +# 1 worker, as on the old server (~160 MB each). --timeout matches the timer client's +# 600 s wait, so a long autofill or peroxide check isn't killed halfway. +CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "600", "--access-logfile", "-", "app:app"] diff --git a/README.md b/README.md index 4aae79d..3b1b710 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,32 @@ ignored unless it is set). ## Running the server +### With Docker (the lab server) + +`deploy/compose.yaml` builds the image from this checkout and runs it with +`config.yaml` and `secrets.yaml` mounted from the repo root (they are never +baked into the image). The container joins the shared `lab-net` network, where +the edge proxy reaches it at `http://eln-server:5000`; the lab's address stays +`https://eln.ddomlab.org:5000` (Caddy sends `/pth/...` to the PTH container and +everything else here). It also listens on `127.0.0.1:5001` for the timer client +on the same host. + +```bash +git clone https://github.com/ddomlab/eln_server.git /opt/eln-server +cd /opt/eln-server +cp config-ex.yaml config.yaml # set eln_url, eln_web_url, IDs +cp secrets.example.yaml secrets.yaml && chmod 600 secrets.yaml # fill in the Slack token etc. +cd deploy && sudo docker compose up -d --build +sudo docker compose ps # expect "healthy" +``` + +The container runs as uid 1000, so both files must be readable by that user +(and `config.yaml` writable, since `/settings_interface` saves to it). +Update: `git pull && sudo docker compose up -d --build`. +`constraints.txt` pins the Python package versions the image installs. + +### Without Docker + Dependencies are managed with [uv](https://docs.astral.sh/uv/) in a local `.venv` (gitignored): @@ -119,3 +145,9 @@ This installs `eln-autofill.timer` (every 10 minutes — adjust `OnCalendar` to taste, including excluding backup windows) and `eln-peroxide-check.timer` (May 1 and Nov 1). The key is stored at `/etc/eln-client/api_key`; the server URL is set via `ELN_SERVER_URL` in the `.service` files. +Pass it at install time to skip editing them; with the Docker deploy on the same +host that's the local port: + +```bash +cd client && sudo ELN_SERVER_URL=http://127.0.0.1:5001 ./install.sh +``` diff --git a/client/install.sh b/client/install.sh index 47532bf..d2ae7a3 100755 --- a/client/install.sh +++ b/client/install.sh @@ -17,6 +17,14 @@ install -m 755 "$SCRIPT_DIR/eln_timer_client.py" /opt/eln-client/ install -m 644 "$SCRIPT_DIR"/eln-autofill.{service,timer} /etc/systemd/system/ install -m 644 "$SCRIPT_DIR"/eln-peroxide-check.{service,timer} /etc/systemd/system/ +# optional: point the timers at a server other than the default in the .service files, +# e.g. sudo ELN_SERVER_URL=http://127.0.0.1:5001 ./install.sh (the Docker deploy's local port) +if [[ -n ${ELN_SERVER_URL:-} ]]; then + sed -i "s|^Environment=ELN_SERVER_URL=.*|Environment=ELN_SERVER_URL=$ELN_SERVER_URL|" \ + /etc/systemd/system/eln-autofill.service /etc/systemd/system/eln-peroxide-check.service + echo "Timers will call $ELN_SERVER_URL" +fi + if [[ ! -f $KEY_FILE ]]; then SECRETS_FILE="$SCRIPT_DIR/../secrets.yaml" if [[ ! -f $SECRETS_FILE ]]; then diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..3801801 --- /dev/null +++ b/constraints.txt @@ -0,0 +1,46 @@ +# Exact versions the offline tests and a local eLabFTW 5.6.12 run passed with (Python 3.11). +# Used by the Dockerfile: pip install -r requirements.txt -c constraints.txt +# To upgrade: bump a version here, rebuild, and run the tests. +blabel==0.1.7 +blinker==1.9.0 +brotli==1.2.0 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.1 +click==8.5.0 +cssselect2==0.10.1 +elabapi-python==5.6.0 +Flask==3.1.3 +flask-cors==6.0.5 +fonttools==4.66.0 +gunicorn==26.2.0 +idna==3.20 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +numpy==2.4.6 +pandas==3.0.6 +pillow==12.3.0 +PubChemPy==1.0.5 +pycparser==3.0 +pydyf==0.12.1 +Pygments==2.21.0 +pypdf==6.19.0 +pyphen==0.18.1 +pyStrich==0.20 +python-barcode==0.16.1 +python-dateutil==2.9.0.post0 +PyYAML==6.0.3 +qrcode==8.2 +rdkit==2026.3.6 +requests==2.34.2 +six==1.17.0 +tabulate==0.10.0 +tinycss2==1.5.1 +tinyhtml5==2.1.0 +typing_extensions==4.16.0 +urllib3==2.8.0 +weasyprint==70.0 +webencodings==0.6.1 +Werkzeug==3.1.9 +zopfli==0.4.3 diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..6a90a25 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,44 @@ +# eln_server on the lab server (run from this folder). +# +# cp ../config-ex.yaml ../config.yaml # then set eln_url, eln_web_url, IDs +# cp ../secrets.example.yaml ../secrets.yaml && chmod 600 ../secrets.yaml # then fill in +# docker compose up -d --build +# +# The edge proxy (Caddy) reaches this container over the shared "lab-net" network at +# http://eln-server:5000. The 127.0.0.1:5001 port is only for the timer client on this +# host (client/): it isn't reachable from outside the server. +name: eln-server + +services: + eln-server: + build: .. + image: eln-server:local + container_name: eln-server + restart: unless-stopped # restart after crashes/reboots, unless you stopped it on purpose + security_opt: [ "no-new-privileges:true" ] + cap_drop: [ ALL ] + mem_limit: 512m # one worker uses ~160 MB; protects eLabFTW on a 2 GB server + ports: + - "127.0.0.1:5001:5000" + volumes: + # both files must exist before the first start; without create_host_path: false, + # Docker would silently create empty folders in their place + - type: bind + source: ../config.yaml # the settings page writes team IDs back to this file + target: /app/config.yaml + bind: { create_host_path: false } + - type: bind + source: ../secrets.yaml + target: /app/secrets.yaml + read_only: true + bind: { create_host_path: false } + networks: + - lab-net + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + +networks: + lab-net: + # created once on the server with: docker network create lab-net + external: true