|
| 1 | +# syntax=docker/dockerfile:1 |
| 2 | + |
| 3 | +# Two-stage build. The first stage has the tooling needed to produce a wheel; |
| 4 | +# the second installs that wheel into a clean image, so the compilers and |
| 5 | +# build-time packages never ship in the final artifact. |
| 6 | +# |
| 7 | +# Build with docker/build.sh, which derives the version from git tags and |
| 8 | +# passes it in. See docker/README.md. |
| 9 | + |
| 10 | +ARG PYTHON_VERSION=3.12 |
| 11 | + |
| 12 | + |
| 13 | +# --- Stage 1: build the wheel ------------------------------------------------ |
| 14 | +FROM python:${PYTHON_VERSION}-slim AS builder |
| 15 | + |
| 16 | +# setuptools-scm normally derives the version from git history, but .git is |
| 17 | +# excluded from the build context (it is ~200MB, and shipping it to the daemon |
| 18 | +# on every build is slow). The version is passed in instead. Without this, |
| 19 | +# setuptools-scm silently falls back to "0.0.0.dev0+unknown" -- the runtime |
| 20 | +# stage asserts against exactly that. |
| 21 | +ARG SETUPTOOLS_SCM_PRETEND_VERSION |
| 22 | +ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} |
| 23 | + |
| 24 | +WORKDIR /src |
| 25 | + |
| 26 | +RUN --mount=type=cache,target=/root/.cache/pip \ |
| 27 | + pip install build |
| 28 | + |
| 29 | +# MANIFEST.in and README.md are referenced by the build (readme is declared in |
| 30 | +# pyproject.toml), so the build fails without them even though only src/ ends |
| 31 | +# up in the wheel. |
| 32 | +COPY pyproject.toml MANIFEST.in README.md LICENSE ./ |
| 33 | +COPY src/ ./src/ |
| 34 | + |
| 35 | +RUN --mount=type=cache,target=/root/.cache/pip \ |
| 36 | + python -m build --wheel --outdir /dist |
| 37 | + |
| 38 | + |
| 39 | +# --- Stage 2: runtime -------------------------------------------------------- |
| 40 | +FROM python:${PYTHON_VERSION}-slim AS runtime |
| 41 | + |
| 42 | +LABEL org.opencontainers.image.title="FinaleToolkit" \ |
| 43 | + org.opencontainers.image.description="Extract fragmentation features of cell-free DNA from paired-end sequencing data." \ |
| 44 | + org.opencontainers.image.source="https://github.com/epifluidlab/FinaleToolkit" \ |
| 45 | + org.opencontainers.image.documentation="https://epifluidlab.github.io/FinaleToolkit/" \ |
| 46 | + org.opencontainers.image.licenses="MIT" |
| 47 | + |
| 48 | +# numba and matplotlib both want a writable cache/config directory and fall |
| 49 | +# back to $HOME, which the non-root user below cannot always write to |
| 50 | +# (e.g. when the image is run with an arbitrary --user). Point them at /tmp, |
| 51 | +# which is writable regardless. |
| 52 | +# |
| 53 | +# PYTHONDONTWRITEBYTECODE is deliberately *not* set: pip precompiles .pyc |
| 54 | +# files at install time, and keeping them means the CLI does not re-parse |
| 55 | +# scipy/numba/matplotlib source on every invocation. This is a short-lived |
| 56 | +# process started many times, so startup cost matters more than image size. |
| 57 | +ENV NUMBA_CACHE_DIR=/tmp/numba-cache \ |
| 58 | + MPLCONFIGDIR=/tmp/matplotlib \ |
| 59 | + PYTHONUNBUFFERED=1 |
| 60 | + |
| 61 | +# Bind-mounting the wheel rather than COPYing it keeps it out of the image's |
| 62 | +# layers -- the installed package is all we need. |
| 63 | +RUN --mount=type=bind,from=builder,source=/dist,target=/dist \ |
| 64 | + --mount=type=cache,target=/root/.cache/pip \ |
| 65 | + pip install /dist/*.whl |
| 66 | + |
| 67 | +# Two smoke checks that catch the failure modes that would otherwise ship |
| 68 | +# silently: |
| 69 | +# |
| 70 | +# 1. Version. Without SETUPTOOLS_SCM_PRETEND_VERSION the package falls back to |
| 71 | +# "0.0.0.dev0+unknown" and the image is untraceable. |
| 72 | +# 2. Package data. The .tsv/.gz files under frag/data and genome/data are |
| 73 | +# pulled in by [tool.setuptools.package-data]; if that ever stops working, |
| 74 | +# the CLI installs cleanly and then fails at run time on the subcommands |
| 75 | +# that need them. |
| 76 | +# 3. Imports. Loading the CLI module confirms the compiled wheels (pysam, |
| 77 | +# numba, pyBigWig, py2bit) actually load on this base image. |
| 78 | +# |
| 79 | +# Bind-mounted rather than COPYed so the script leaves no layer behind. |
| 80 | +RUN --mount=type=bind,source=docker/verify.py,target=/tmp/verify.py \ |
| 81 | + python /tmp/verify.py |
| 82 | + |
| 83 | +RUN useradd --create-home --uid 1000 finaletoolkit \ |
| 84 | + && mkdir -p /data \ |
| 85 | + && chown finaletoolkit:finaletoolkit /data |
| 86 | + |
| 87 | +USER finaletoolkit |
| 88 | + |
| 89 | +# Conventional mount point for input BAM/CRAM files and outputs: |
| 90 | +# docker run -v /host/path:/data <image> <subcommand> /data/sample.bam ... |
| 91 | +WORKDIR /data |
| 92 | + |
| 93 | +# ENTRYPOINT makes the image behave as the CLI itself, so `docker run <image> |
| 94 | +# frag-length-bins ...` works the same way the installed command does. |
| 95 | +ENTRYPOINT ["finaletoolkit"] |
| 96 | +CMD ["--help"] |
| 97 | + |
| 98 | + |
| 99 | +# --- Stage 3: test ----------------------------------------------------------- |
| 100 | +# A strict superset of the runtime image: same environment, plus pytest. The |
| 101 | +# 91MB of fixtures under tests/ are mounted at run time rather than baked in. |
| 102 | +# |
| 103 | +# docker build --target test -t finaletoolkit:test . |
| 104 | +# docker run --rm -v "$PWD/tests:/app/tests:ro" --entrypoint pytest \ |
| 105 | +# finaletoolkit:test |
| 106 | +# |
| 107 | +# The ENTRYPOINT is deliberately left as-is so this image still works as a |
| 108 | +# plain CLI if it is built by accident (it is the last stage, so a bare |
| 109 | +# `docker build .` lands here). |
| 110 | +FROM runtime AS test |
| 111 | + |
| 112 | +USER root |
| 113 | +RUN --mount=type=cache,target=/root/.cache/pip \ |
| 114 | + pip install pytest pytest-cov |
| 115 | + |
| 116 | +# pytest reads [tool.pytest.ini_options] from pyproject.toml, which sets |
| 117 | +# testpaths = ["tests"]; this also makes /app the rootdir. It has to be |
| 118 | +# writable by the non-root user, since pytest writes .pytest_cache there and |
| 119 | +# --cov writes .coverage. |
| 120 | +RUN mkdir -p /app && chown finaletoolkit:finaletoolkit /app |
| 121 | +WORKDIR /app |
| 122 | +COPY --chown=finaletoolkit:finaletoolkit pyproject.toml ./ |
| 123 | + |
| 124 | +USER finaletoolkit |
0 commit comments