mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
### What this PR does
`uv` stops being copied into the image and starts being mounted into the
three `RUN`s that
actually use it. The digest pin stays in exactly one place — it moves
from the `COPY` to a
stage declaration:
```dockerfile
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c... AS uv
```
```dockerfile
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups
```
A stage consumed only through `--mount=from=` contributes no layer to
anything published, so
`uv` never lands in `base`. The two `RUN rm -f /usr/bin/uv /usr/bin/uvx`
lines then have nothing
left to delete and go with it.
### Why
The `base` stage copies uv in, and both final stages try to take it back
out:
```dockerfile
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
```
That intent is exactly right. **The mechanism can't carry it out**: a
`RUN` adds a layer, it
does not rewrite the layer underneath. The `COPY` layer is still pushed
and still pulled by
everyone. What the `rm` produces is a whiteout on top of it.
### Measured, not assumed
Read off the published images over the registry API — `linux/amd64`,
both built
`2026-08-13T17:53Z`, pinned by digest so these numbers stay reproducible
after tonight's
scheduled rebuild:
```
ghcr.io/calibrain/shelfmark@sha256:9b6041f797cbcc1e5c50ab42bd010a8f747dfaac926080cb6269ddfae99cf820
layer COPY /uv /uvx /bin/ 24.3 MB of 585 MB total 4.1% of the pull
layer RUN rm -f /usr/bin/uv /usr/bin/uvx 159 B
ghcr.io/calibrain/shelfmark-lite@sha256:2eae503d791cef685e10135aaaff77077cfb4a31d6911e31216704988ce28b02
layer COPY /uv /uvx /bin/ 24.3 MB of 221 MB total 11.0% of the pull
```
The `rm` layer unpacks to exactly four tar entries:
```
usr/
usr/bin/
usr/bin/.wh.uv 0 bytes
usr/bin/.wh.uvx 0 bytes
```
Two zero-length overlayfs whiteouts. That is the deletion behaving
exactly as specified — and
removing nothing at all from what anyone downloads.
Same thing without the registry API:
```
$ docker manifest inspect ghcr.io/calibrain/shelfmark-lite:latest
```
and look for the ~24 MB layer; or `docker history` on a local build.
### To be clear about what the `rm` does and doesn't do
**It is not useless and I'm not claiming it is.** It removes `uv` from
the flattened filesystem,
which is what the container sees at runtime and what Trivy/Grype scan by
default — so the
"don't ship a stale installer" half of the intent is already working
today, the same way the
`pip` removal above it does. This PR is about the other half: the bytes.
After it, `uv` is
absent from the filesystem *and* absent from the layers, so nothing
regresses.
This is image size, not a vulnerability, and I would not have opened it
as anything else.
### Why this is safe
- **Nothing at runtime can depend on `uv` or `uvx` today**, and that is
read off your own
artifact rather than argued: both are already whiteouted out of both
published images. `uvx`
is never invoked anywhere in the repo — `entrypoint.sh` has no `uv` in
it, and the Makefile's
`uv run` lines are the host-side dev workflow, outside the image.
- `/usr/local/bin` is already on `PATH` in `python:3.14.7-slim`, and
your `ENV PATH=/app/.venv/bin:$PATH`
prepends rather than replaces, so `uv` resolves the same way it does
now.
- The pin does not move. Same image, same `sha256`, same resolution per
target platform as
`COPY --from=<image>` does today, so the `linux/amd64` and `linux/arm64`
builds each keep
getting their own `uv`.
- `RUN --mount=` is already used three times in this file, so the
frontend in use supports
mounts; `from=` is part of the same feature.
- Your `docker-build-check` job builds `shelfmark-lite` on every PR, so
a build is the cheapest
possible review of this change. As a first-time contributor my workflow
runs sit at
`action_required` until someone approves them — approving is enough to
check the whole claim.
### Notes for reviewers
- I have **not** built these images locally. There is no Docker daemon
on the machine I run on.
Every figure above is read from the published images over the registry
API, and my own
selftest re-reads them live on each run rather than trusting a note.
- I left the `pip` removal in `base` alone. It has the same shape, but
its stated goal — keeping
a stale installer out of what scanners see — is genuinely achieved by
the flattened
filesystem, and `pip` arrives in the `python:slim` base layer where a
Dockerfile change can't
reach it anyway.
- Written by an automated agent; saying so plainly seemed better than
not.
Signed-off-by: Sujeito Operator <operator@sujeito.org>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
243 lines
9.8 KiB
Docker
243 lines
9.8 KiB
Docker
ARG TARGETPLATFORM
|
|
ARG TARGETARCH
|
|
ARG BUILDPLATFORM
|
|
ARG BUILDARCH
|
|
|
|
# Frontend build stage.
|
|
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend-builder
|
|
|
|
# Helpful debug output to see what platforms BuildKit thinks it's using
|
|
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
|
|
|
WORKDIR /frontend
|
|
|
|
# Copy frontend package files
|
|
COPY src/frontend/package*.json ./
|
|
|
|
# Install dependencies (cache mount for faster rebuilds)
|
|
RUN --mount=type=cache,target=/root/.npm \
|
|
npm ci
|
|
|
|
# Copy frontend source
|
|
COPY src/frontend/ ./
|
|
|
|
# Build the frontend
|
|
RUN npm run build
|
|
|
|
# uv is a build-time tool only, so it is mounted into the RUNs that need it rather
|
|
# than copied into the image. A COPY here would land ~24 MB in a `base` layer that
|
|
# every published image inherits, and a later `rm` cannot take it back out again --
|
|
# a RUN adds a layer, it does not rewrite the one underneath.
|
|
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv
|
|
|
|
# Use python-slim as the base image
|
|
FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base
|
|
|
|
# Add build argument for version
|
|
ARG BUILD_VERSION
|
|
ENV BUILD_VERSION=${BUILD_VERSION}
|
|
ARG RELEASE_VERSION
|
|
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
|
|
|
# Set shell to bash with pipefail option
|
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|
|
|
# Consistent environment variables grouped together
|
|
ENV DEBIAN_FRONTEND=noninteractive \
|
|
DOCKERMODE=true \
|
|
UV_LINK_MODE=copy \
|
|
PYTHONUNBUFFERED=1 \
|
|
PYTHONDONTWRITEBYTECODE=1 \
|
|
PYTHONIOENCODING=UTF-8 \
|
|
NAME=Shelfmark \
|
|
PATH=/app/.venv/bin:$PATH \
|
|
PYTHONPATH=/app \
|
|
# PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed
|
|
LANG=en_US.UTF-8 \
|
|
LANGUAGE=en_US:en \
|
|
LC_ALL=en_US.UTF-8
|
|
|
|
# Set ARG for build-time expansion (FLASK_PORT), ENV for runtime access
|
|
ENV FLASK_PORT=8084
|
|
|
|
# Configure locale, timezone, and perform initial cleanup in a single layer
|
|
RUN apt-get update && \
|
|
apt-get install -y --no-install-recommends \
|
|
# For building C-extensions (cffi, gevent, etc.)
|
|
gcc \
|
|
g++ \
|
|
libffi-dev \
|
|
python3-dev \
|
|
# For locale
|
|
locales tzdata \
|
|
# For healthcheck
|
|
curl \
|
|
# For entrypoint
|
|
dumb-init \
|
|
# For debug
|
|
zip iputils-ping \
|
|
# For user switching
|
|
gosu \
|
|
# --- Tor support (activated via USING_TOR=true) ---
|
|
tor \
|
|
supervisor \
|
|
iptables \
|
|
# --- WireGuard support (activated via USING_WIREGUARD=true) ---
|
|
wireguard-tools \
|
|
iproute2 \
|
|
procps \
|
|
ca-certificates && \
|
|
# Configure iptables alternatives for tor.sh compatibility
|
|
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
|
|
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
|
|
# Cleanup APT cache *after* all installs in this layer
|
|
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
|
|
apt-get clean && \
|
|
rm -rf /var/lib/apt/lists/* && \
|
|
# Default to UTC timezone but will be overridden by the entrypoint script
|
|
ln -snf /usr/share/zoneinfo/UTC /etc/localtime && echo UTC > /etc/timezone && \
|
|
# Configure locale
|
|
sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \
|
|
locale-gen en_US.UTF-8 && \
|
|
echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \
|
|
echo "LANG=en_US.UTF-8" > /etc/locale.conf
|
|
|
|
# Create a fixed runtime user/group so hardened Docker/Kubernetes deployments
|
|
# can start the container directly as a non-root user with a passwd entry.
|
|
RUN groupadd -g 1000 shelfmark && \
|
|
useradd -u 1000 -g shelfmark -d /home/shelfmark -s /usr/sbin/nologin shelfmark && \
|
|
mkdir -p /home/shelfmark && \
|
|
chown 1000:1000 /home/shelfmark
|
|
|
|
# Set working directory
|
|
WORKDIR /app
|
|
|
|
# Install core Python dependencies first for better layer caching
|
|
COPY pyproject.toml uv.lock ./
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
|
|
uv sync --locked --no-default-groups
|
|
|
|
# Runtime dependencies are installed into /app/.venv during the build. Remove the
|
|
# base image's system pip so stale installer CVEs do not ship in the final image.
|
|
RUN rm -rf \
|
|
/usr/local/bin/pip \
|
|
/usr/local/bin/pip3 \
|
|
/usr/local/bin/pip3.* \
|
|
/usr/local/lib/python*/site-packages/pip \
|
|
/usr/local/lib/python*/site-packages/pip-*.dist-info
|
|
|
|
# Copy application code *after* dependencies are installed
|
|
COPY . .
|
|
|
|
# Copy built frontend from frontend-builder stage
|
|
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
|
|
|
|
# Final setup: create image-owned runtime paths for the fixed non-root user.
|
|
# Root/PUID mode still re-homes ownership at startup when needed.
|
|
RUN mkdir -p \
|
|
/config \
|
|
/books \
|
|
/var/log/shelfmark \
|
|
/tmp/shelfmark/seleniumbase/downloaded_files \
|
|
/tmp/shelfmark/seleniumbase/archived_files && \
|
|
rm -rf /app/downloaded_files /app/archived_files && \
|
|
ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \
|
|
ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \
|
|
chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \
|
|
chmod -R a+rX /app && \
|
|
chmod +x /app/entrypoint.sh /app/tor.sh /app/wireguard.sh /app/genDebug.sh
|
|
|
|
# Expose the application port
|
|
EXPOSE ${FLASK_PORT}
|
|
|
|
# Add healthcheck for container status
|
|
# Uses /api/health which doesn't require authentication
|
|
HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
|
|
CMD curl -s http://localhost:${FLASK_PORT}/api/health > /dev/null || exit 1
|
|
|
|
# Use dumb-init as the entrypoint to handle signals properly
|
|
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
|
|
|
|
|
FROM base AS shelfmark
|
|
|
|
# --- Chromium (PINNED to 149.0.7827.196) ---
|
|
# Debian's chromium 150.0.7871.46-1~deb13u1 security update (trixie-security,
|
|
# 2026-07-05) no longer opens the DevTools remote-debugging TCP port at all
|
|
# (no listener, no DevToolsActivePort file, even with a custom --user-data-dir;
|
|
# the RemoteDebuggingAllowed policy does not restore it). The SeleniumBase
|
|
# Pure-CDP driver connects through that port (/json/version), so with 150 every
|
|
# internal bypass dies with "Pure CDP browser startup failed" and all
|
|
# CF-gated downloads fail. Install the last working version from
|
|
# snapshot.debian.org until the bypasser can talk to Chromium >= 150 (e.g.
|
|
# pipe-based DevTools / UC mode) or seleniumbase ships a fix.
|
|
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
|
|
# This flag is set in internal_bypasser.py _get_browser_args()
|
|
ARG CHROMIUM_VERSION=149.0.7827.196-1~deb13u1
|
|
ARG CHROMIUM_SNAPSHOT=20260704T000000Z
|
|
|
|
RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-security/${CHROMIUM_SNAPSHOT}/ trixie-security main" \
|
|
> /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
|
|
apt-get update -o Acquire::Retries=5 && \
|
|
apt-get install -y --no-install-recommends -o Acquire::Retries=5 \
|
|
# For dumb display
|
|
xvfb \
|
|
# For screen recording
|
|
ffmpeg \
|
|
chromium=${CHROMIUM_VERSION} \
|
|
chromium-common=${CHROMIUM_VERSION} \
|
|
# For tkinter (pyautogui)
|
|
python3-tk \
|
|
# For RAR extraction
|
|
unrar-free && \
|
|
# Keep apt from "upgrading" chromium past the pin inside derived images
|
|
printf 'Package: chromium chromium-common\nPin: version %s\nPin-Priority: 1001\n' "${CHROMIUM_VERSION}" \
|
|
> /etc/apt/preferences.d/chromium-pin && \
|
|
rm /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
|
|
# Create symlink so rarfile library can find unrar
|
|
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
|
|
# Cleanup APT cache
|
|
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
|
|
apt-get clean && \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# Install the browser automation stack used by the full image
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
|
|
uv sync --locked --no-default-groups --extra browser
|
|
|
|
# Deterministically resolve the Xlib namespace collision.
|
|
# pyautogui/mouseinfo pull the stale `python3-xlib` (0.15, 2014), while the
|
|
# `--extra browser` set pulls `python-xlib` (0.33). Both packages install into
|
|
# the same top-level `Xlib/` namespace, so whichever lands last wins. When the
|
|
# 2014 build wins, `Xlib.X` is missing `FamilyServerInterpreted`, which the
|
|
# SeleniumBase Pure-CDP driver requires at browser startup -> every bypass fails
|
|
# with "module 'Xlib.X' has no attribute 'FamilyServerInterpreted'" and no
|
|
# Cloudflare/DDoS-Guard protected download can complete. Drop the stale package
|
|
# and force python-xlib 0.33 to own the namespace. pyautogui runs fine against
|
|
# 0.33 (superset API).
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
|
|
uv pip uninstall --python /app/.venv/bin/python python3-xlib && \
|
|
uv pip install --python /app/.venv/bin/python --reinstall python-xlib==0.33 && \
|
|
/app/.venv/bin/python -c "import Xlib.X; assert hasattr(Xlib.X, 'FamilyServerInterpreted'), 'Xlib.X.FamilyServerInterpreted missing after fix'; print('Xlib namespace OK:', Xlib.__version__)"
|
|
|
|
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
|
|
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
|
|
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
|
|
chmod -R u+rwX,go+rX "${SELENIUMBASE_DRIVERS_DIR}" && \
|
|
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver"; fi
|
|
|
|
# Grant read/execute permissions to others
|
|
RUN chmod -R o+rx /usr/bin/chromium
|
|
|
|
# Default command to run the application entrypoint script
|
|
CMD ["/app/entrypoint.sh"]
|
|
|
|
FROM base AS shelfmark-lite
|
|
|
|
ENV USING_EXTERNAL_BYPASSER=true
|
|
|
|
CMD ["/app/entrypoint.sh"]
|