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