-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDockerfile
More file actions
159 lines (140 loc) · 8.71 KB
/
Copy pathDockerfile
File metadata and controls
159 lines (140 loc) · 8.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# syntax=docker/dockerfile:1
#
# Official cronstable image: a minimal, non-root, multi-arch build
#
# Build locally:
# docker build -t cronstable .
# The version is read from git during the build; CI passes the released version
# explicitly via --build-arg VERSION=X.Y.Z.
# ---- build stage --------------------------------------------------------
FROM python:3.14-slim AS builder
WORKDIR /src
# Layer contract, load-bearing for CI speed: everything from here through the
# orjson step reads nothing from the source tree except pyproject.toml and
# the docker/ helper scripts (extract_deps.py, install_orjson.sh) and never
# references the per-commit VERSION arg.
# These are the expensive layers (toolchain plus every third-party
# dependency, source-compiled under QEMU on the wheel-less arches), and
# keeping per-commit inputs out of them lets buildx's GHA cache reuse them
# across source-only commits; a typical push rebuilds only the cheap project
# install at the bottom of this stage.
#
# CI sets DEPS_REFRESH to the workflow run id on a RELEASE, deliberately
# missing the cache so a release re-resolves every dependency fresh from the
# index (the freshness every release had when each push rebuilt these layers).
# Ordinary pushes leave it "" and ride the cache, which also refreshes
# whenever pyproject.toml, this file, or the base image digest changes.
ARG DEPS_REFRESH=""
# build-essential + libffi/zlib headers let pip source-compile the C-extension
# deps that ship no wheel on some targets — notably the aiohttp stack on 32-bit
# x86 (linux/386), propcache on 32-bit ARM (linux/arm/v7), and
# multidict/frozenlist/ruamel.yaml.clib on linux/riscv64 (no riscv64 wheels yet).
# On amd64/arm64 the whole stack is prebuilt wheels, so the toolchain goes
# unused; either way it stays in this builder stage and never reaches the slim
# runtime image. git is now installed unconditionally: a plain `docker build .`
# needs it for setuptools_scm to read .git, and gating it on VERSION (as this
# layer once did) would put the per-commit version into the cache key and
# rebuild the toolchain on every push.
# retry() re-runs a network step (package install, pip download) a few times
# with backoff, so a transient mirror/index hiccup does not fail the build.
RUN set -eux; \
: "deps-refresh=${DEPS_REFRESH}"; \
retry() { n=0; until "$@"; do n=$((n+1)); if [ "$n" -ge 5 ]; then return 1; fi; echo "retry $n: $*"; sleep $((n*5)); done; }; \
retry apt-get -o Acquire::Retries=5 update; \
retry apt-get -o Acquire::Retries=5 install -y --no-install-recommends build-essential libffi-dev zlib1g-dev git; \
rm -rf /var/lib/apt/lists/*
# Only pyproject.toml and the shared extraction helper reach the dependency
# layer: its cache key is the dependency metadata plus the small script that
# reads it, never the rest of the tree.
COPY pyproject.toml /tmp/deps/pyproject.toml
COPY docker/extract_deps.py /tmp/deps/extract_deps.py
# Install the third-party dependencies into a self-contained venv so the
# runtime stage can copy just that, leaving the build toolchain behind. The
# venv lives at the same path in both stages (both are python:3.14-slim), so
# its interpreter symlinks stay valid.
#
# The requirement strings are read out of pyproject.toml by the COPYd
# extract_deps.py helper (the core dependencies plus the push and discovery
# extras), the exact strings `pip install ".[push,discovery]"` would
# resolve, so the two can never drift, and a renamed extra fails the build
# loudly (KeyError) instead of silently shipping without it. The build
# backend (build-system.requires) goes into a separate throwaway venv,
# /tmp/deps/buildenv: the project install below uses it to build the wheel
# without network access, and it never pollutes the shipped /opt/venv.
RUN set -eux; \
retry() { n=0; until "$@"; do n=$((n+1)); if [ "$n" -ge 5 ]; then return 1; fi; echo "retry $n: $*"; sleep $((n*5)); done; }; \
python -m venv /opt/venv; \
retry /opt/venv/bin/pip install --no-cache-dir --upgrade pip; \
/opt/venv/bin/python /tmp/deps/extract_deps.py /tmp/deps/pyproject.toml; \
retry /opt/venv/bin/pip install --no-cache-dir --timeout 60 -r /tmp/deps/requirements.txt; \
python -m venv /tmp/deps/buildenv; \
retry /tmp/deps/buildenv/bin/pip install --no-cache-dir --timeout 60 -r /tmp/deps/build-requires.txt
# The push (PyNaCl) and discovery (zeroconf) extras above are part of the
# image contract: a `push:` or `web.bonjour` section fails closed at config
# load, so an image missing them would crash-loop with no in-image
# remediation (pip is stripped from the runtime stage below). Verify with a
# real sealed-box round-trip (catches a QEMU-miscompiled libsodium on the
# source-built arches) plus a zeroconf import; a failure fails the build.
RUN /opt/venv/bin/python -c 'from nacl.public import PrivateKey, SealedBox; k = PrivateKey.generate(); m = b"cronstable push self-test"; assert SealedBox(k).decrypt(SealedBox(k.public_key).encrypt(m)) == m; import zeroconf, zeroconf.asyncio; print("push and discovery extras verified")'
# Best-effort orjson (the `speedups` extra) to accelerate the durable-state
# and cluster-gossip JSON paths; docker/install_orjson.sh never fails the
# build and never ships a broken orjson (cronstable/_json falls back to the
# stdlib json when it is absent). Almost every arch
# (amd64/arm64/386/armv7/ppc64le/s390x) installs a manylinux wheel; only
# riscv64 has none, so RUST_SETUP installs a CURRENT Rust via rustup
# (Debian's packaged rustc is older than orjson's MSRV) for the source
# build. The script is COPYd here, below the dependency layers, so editing
# it never invalidates their cache.
COPY docker/install_orjson.sh /tmp/deps/install_orjson.sh
RUN set -eux; \
RUST_SETUP="apt-get -o Acquire::Retries=5 update \
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends curl ca-certificates \
&& curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | env CARGO_HOME=/opt/cargo RUSTUP_HOME=/opt/rustup sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path" \
sh /tmp/deps/install_orjson.sh "orjson>=3.9"; \
rm -rf /var/lib/apt/lists/*
# ---- project install: the only per-commit layers ------------------------
# In CI we pass the already-computed release version so the build is
# deterministic and needs no git history. A plain `docker build .` leaves it
# empty and setuptools_scm reads the version from .git (which .dockerignore
# deliberately keeps in the build context, and the toolchain layer's git
# binary serves).
ARG VERSION=""
COPY . .
# Build the wheel with the cached buildenv's backend (--no-build-isolation:
# no per-commit network fetch of setuptools/setuptools_scm) and install it
# with --no-deps: every dependency is already in /opt/venv from the cached
# layer above, and adding one edits pyproject.toml, which rebuilds that
# layer. `pip check` then proves the split left nothing unsatisfied.
RUN set -eux; \
if [ -n "$VERSION" ]; then export SETUPTOOLS_SCM_PRETEND_VERSION="$VERSION"; fi; \
/tmp/deps/buildenv/bin/pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/deps/wheelhouse .; \
/opt/venv/bin/pip install --no-cache-dir --no-deps /tmp/deps/wheelhouse/cronstable-*.whl; \
/opt/venv/bin/pip check
# Drop pip from the venv before it ships. The runtime image installs nothing
# (no package manager, read-only root filesystem), so pip and its vendored
# bundle are pure dead weight in the copied /opt/venv. Nothing at runtime
# imports pip, so removing it is safe; setuptools stays for any dependency that
# still reaches for pkg_resources.
RUN /opt/venv/bin/pip uninstall -y pip || true
# ---- runtime stage ------------------------------------------------------
FROM python:3.14-slim
LABEL org.opencontainers.image.title="cronstable" \
org.opencontainers.image.description="A modern, rootless-container-friendly cron replacement." \
org.opencontainers.image.source="https://github.com/ptweezy/cronstable" \
org.opencontainers.image.url="https://github.com/ptweezy/cronstable" \
org.opencontainers.image.documentation="https://github.com/ptweezy/cronstable/wiki" \
org.opencontainers.image.authors="Parker Loflin <parker@cronstable.dev>" \
org.opencontainers.image.vendor="Parker Loflin" \
org.opencontainers.image.licenses="MIT"
# Flush stdout/stderr immediately (cronstable logs to them) and never write .pyc
# files because of the read-only root filesystem.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
COPY --from=builder /opt/venv /opt/venv
# Run as an unprivileged, non-root user (65534 = "nobody"). Per-job user/group
# switching is unavailable in this mode; dropping root gives a fully
# locked-down container.
USER 65534:65534
ENTRYPOINT ["cronstable"]
CMD ["-c", "/etc/cronstable.d"]