Ctrl K
ComunidadGuías…

Dockerfile

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Build from the repository root:
#   docker build -t stocktake .
#
# Multi-arch, the way the release workflow does it:
#   docker buildx build --platform linux/amd64,linux/arm64 \
#     -t ghcr.io/jarrodruggiero/stocktake:X.Y.Z --push .
#
# Multi-stage so the test suite runs against the same layers the app ships from.
# `runtime` is the last stage and therefore the default target, so the
# production build is unchanged; docker only builds the stages its target
# depends on, so `test` is skipped there.
#   tests: docker build --target test -t stocktake-test . \
#          && docker run --rm stocktake-test
#
# Every image is pinned BY DIGEST, with its tag beside it. The digest makes
# the build reproducible: `python:3.14-slim` is a different image next month,
# and a build that cannot be reproduced cannot be audited after an incident.
# The tag is what Dependabot follows. With the digest alone it followed
# `latest`, the full image built on buildpack-deps with compilers, git and
# curl: 415 MB compressed against 43 MB, three times, each in a pull request
# titled with two short digests. tests/test_image.py holds both rules.
#
# Slim also keeps the test stage honest: nothing needs a toolchain, because
# every dependency in uv.lock resolves from a wheel, and on slim one that
# started compiling from source fails the build rather than passing quietly.
# uv, pinned the same way, and on a FROM line of its own: Dependabot reads FROM
# lines and nothing else, so while uv was copied straight out of its image it
# was never proposed an update, and stayed at the version of the first commit.
FROM ghcr.io/astral-sh/uv:0.13.6@sha256:af22a52f5eafef3d78e551599d405c02df61919d18028f0cea8eda93bc9986d0 AS uv

# --------------------------------------------------------------------------- #
# build — the virtualenv, made where git and uv are. Nothing below ships them.
# --------------------------------------------------------------------------- #
# Named as Docker Hub's, because Dependabot reads an image's tags from the
# registry its FROM line names, and Google's mirror lists only the tags someone
# has already pulled through it: under the mirror's name, 3.15-slim was never
# proposed. CI still pulls through the mirror, which its BuildKit builders are
# configured to prefer, so Docker Hub's rate limit on shared CI addresses
# doesn't apply; only if the mirror fails does BuildKit fall back to Docker Hub.
# The digest is the same either way.
FROM docker.io/library/python:3.14-slim@sha256:cd89c30eda26ee911a4611b18680086e0ea42e6f5f8cbe653d07fa7958c9cec8 AS build

# git, because appcore is a pinned git dependency and uv clones it. The full
# image had git, which is how a "slim" pin that kept turning full went
# unnoticed: on slim alone, `uv sync` cannot fetch appcore. Here only.
RUN apt-get update \
    && apt-get install -y --no-install-recommends git \
    && rm -rf /var/lib/apt/lists/*

COPY --from=uv /uv /usr/local/bin/uv

WORKDIR /srv/stocktake
# Dependencies come from uv.lock — the single source of truth. The list used to
# be hand-duplicated here with a "keep in step with pyproject.toml" comment,
# which is a drift bug waiting to happen: the image would install a different
# set from the one the tests ran against, and nothing would say so.
COPY pyproject.toml uv.lock ./
# --frozen: fail if the lock is out of date with pyproject rather than silently
# re-resolving, so a forgotten `uv lock` breaks the build instead of shipping
# unreviewed versions. --no-install-project: only the app's source is copied
# below, and installing it as a package would need a rebuild on every edit.
# --no-cache: uv keeps a download cache under ~/.cache/uv and hardlinks out of
# it. Hardlinks do not survive being committed to an image layer, so the cache
# ships as a second full copy of every wheel. Nothing in this image ever
# re-resolves, so the cache has no reader.
RUN uv sync --frozen --no-dev --no-install-project --no-cache

# The same, with the development dependencies, for the test stage alone.
FROM build AS build-test
RUN uv sync --frozen --no-install-project --no-cache

# --------------------------------------------------------------------------- #
# base — what ships: the interpreter, OCR, the virtualenv and the source.
# --------------------------------------------------------------------------- #
FROM docker.io/library/python:3.14-slim@sha256:cd89c30eda26ee911a4611b18680086e0ea42e6f5f8cbe653d07fa7958c9cec8 AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1

# The unprivileged user the app runs as, which owns only what the app writes:
# /data, /config and /scratch. The code and the virtualenv stay root's, so the
# process cannot rewrite itself.
#
# Never a recursive chown over the virtualenv: it rewrites every file it
# touches into a new layer. With a 281 MB virtualenv that once shipped it
# TWICE, 300 MB to build and another 297 MB to change its ownership, and it
# looked like new dependencies rather than a chown.
#
# The uid is fixed at 1000 rather than left to the distro because it has to be
# predictable from outside the container:
#
#   * Kubernetes — set `fsGroup: 1000` on the pod so the mounted PVC is
#     group-writable. Without it the volume arrives owned by root and the app
#     cannot create its database.
#   * Docker/compose — a named volume inherits the image's ownership and just
#     works. A BIND mount does not: `chown -R 1000:1000 ./data` on the host
#     first, or the same failure.
#
# /data and /config are created and owned here so the common case needs no
# intervention. /config matters as much as /data now that the first-run wizard
# WRITES config.yaml: left to Docker to create, a named volume mounted there
# arrives owned by root, the app cannot write it, and the wizard quietly
# degrades to "here is the YAML, save it yourself" on every install. Creating
# it in the image means the volume inherits uid 1000 and the wizard can save.
#
# A ConfigMap mount over the top is still read-only, and still handled — that
# is a deliberate deployment choice rather than an accident of ownership.

# Tesseract, for statements that arrive as scans. Local OCR is the only kind
# this app will ever do — a dividend statement carries a name, an address and a
# holder number, and the standing promise is that only ticker symbols leave the
# machine. See app/ocr.py.
#
# MEASURED at 107 MB (37 MB of it libicu, pulled in by leptonica; 15 MB the
# English language data). That is a real cost and it was weighed: the same
# measurement pass found this image was shipping its virtualenv twice, worth
# 297 MB, so OCR went in and the image still came out ~370 MB smaller than the
# version before it.
#
# Placed before the venv so it stays cached across app changes, and while still
# root — everything below runs as uid 1000.
RUN apt-get update \
    && apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
    && rm -rf /var/lib/apt/lists/*

# `/scratch` is created here for the same reason /data and /config are: the app
# runs as uid 1000, and `/` is root-owned, so it could not make a top-level
# directory for itself. It holds the visual mapper's rendered pages — see
# `imports.visual_dir` in app/settings.py — and it is DELIBERATELY not a volume.
# Anything here is disposable and must not be backed up.
#
# Mode 1777, which is what /tmp is: writable by anyone, sticky so nobody can
# remove another user's files. /data and /config do NOT need it because they are
# always mounted over, and the mount's ownership is what applies — but /scratch
# is deliberately never mounted, so the image's own ownership is what the app
# meets. Owned by uid 1000, an operator running `--user 99:100` (which the
# Unraid template does, because appdata is nobody:users) could not create
# /scratch/visual and every visual statement import failed. Owning it 99:100
# instead would only move the failure onto uid 1000; 1777 works for whatever
# uid the operator picks.
RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin stocktake \
    && mkdir -p /srv/stocktake /data /config /scratch \
    && chown 1000:1000 /data /config \
    && chmod 1777 /scratch
WORKDIR /srv/stocktake

# The virtualenv from `build`, at the same path, on the same interpreter, and
# the files that describe it: pytest's settings live in pyproject.toml, and
# without them the test stage runs with no markers and no deselection.
COPY --from=build /srv/stocktake/.venv ./.venv
COPY pyproject.toml uv.lock ./
# For `alembic` run by hand, as docs/reference/cli.md shows. The app migrates
# itself at startup without it.
COPY alembic.ini ./

# The annotated default config, shipped so the first-run wizard has something
# to write from. Without it a fresh container has no config.yaml and no way to
# make one, which is the situation the wizard exists to fix.
COPY config.yaml ./config.default.yaml

# App source last: it changes most often, so everything above stays cached.
# Migrations live inside app/migrations so they ship with the source.
COPY app ./app
ENV PATH="/srv/stocktake/.venv/bin:$PATH"

# --------------------------------------------------------------------------- #
# test — the same image plus the suite. Never part of the shipped layers.
# --------------------------------------------------------------------------- #
FROM base AS test
COPY --from=build-test /srv/stocktake/.venv ./.venv
# The suite's own files are its user's, unlike the app's: the wizard tests save
# the suite's config.yaml, and a config nobody can write turns those steps off.
# Nothing in this stage ships.
COPY --chown=1000:1000 tests ./tests
# Files the suite READS but the runtime image has no use for. Without them the
# containerised run is not the same run as the local one, which is the whole
# claim this stage makes — and it silently was not: the alert-rule tests and
# two config tests had been failing here while `pytest` passed on a laptop.
#
#   config.yaml      — the annotated default. `base` copies it as
#                      config.default.yaml, which is the name the app wants and
#                      not the one the tests look for.
#   deploy/          — the shipped manifests and Prometheus rules. The rule
#                      tests parse them; test_pagemap checks the manifests
#                      mount the scratch directory the app writes to.
#   docs/, AGENTS.md — the documentation tests: every setting appears in the
#                      configuration reference, every module on the
#                      architecture map, every route documented is served.
#   README.md        — test_docs_match_the_code.py reads it with the docs:
#                      every path a page lints exists, and no line renders as
#                      a heading by accident.
#   mkdocs.yml       — test_doc_links.py: every "More information" link in the
#                      app resolves to a page that exists AND is in the nav.
#                      The nav half is the part that needs this file.
#   .github/         — test_workflows.py: every path a workflow lints has to
#                      exist, and the release has to lint what CI lints. Its
#                      own smoke test is what caught this omission — with the
#                      directory missing the sweep found no workflows, and a
#                      guard over nothing passes.
#   tools/           — test_branding.py imports tools/render_brand.py to prove
#                      every generated brand file still matches branding.py.
#                      It went missing once, and it did not fail loudly: the
#                      import error stopped COLLECTION, so the containerised
#                      run reported an error rather than the full pass count
#                      and was easy to read as a flake. This stage has silently
#                      stopped being the same run as the local one more than
#                      once — check here first when the two disagree.
COPY config.yaml ./config.yaml
COPY deploy ./deploy
COPY tools ./tools
COPY docs ./docs
COPY AGENTS.md README.md ./
COPY mkdocs.yml ./mkdocs.yml
COPY .github ./.github
# This file, because test_pagemap checks the image really does create the
# scratch directory the settings point at.
COPY Dockerfile .dockerignore ./
# Each test builds its database under pytest's tmp_path, so nothing is mounted.
# Run as the app's user, against code it cannot write, as it ships; so pytest
# keeps no cache in the working directory.
USER 1000:1000
CMD ["python", "-m", "pytest", "tests", "-q", "-p", "no:cacheprovider"]

# --------------------------------------------------------------------------- #
# runtime — what gets pushed. Last stage = the default build target.
# --------------------------------------------------------------------------- #
FROM base AS runtime
USER 1000:1000

# The user, the ownership and the directories were all settled in `base`.
VOLUME ["/data"]

EXPOSE 8000
# Docker's own view of whether the app answers, for compose and Unraid; on
# Kubernetes the chart's probes do this. /healthz needs no database, and the
# image has no curl, so Python asks it.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
# config.yaml is mounted from a ConfigMap at /config/config.yaml in-cluster;
# on compose/Unraid the wizard writes it there itself on first run.
# Migrations run at startup (appcore.upgrade_to_head) before serving.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]