From 29a60c9c043e32937a8364aff6a2be2b55fd7d38 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:49:09 -0600 Subject: [PATCH] Keep the Docker image's toolchain layers stable across releases, and slim them CLI_VERSION was declared at the top of the Dockerfile. Every RUN after an ARG is cache-keyed on its value, and CLI_VERSION changes on every release, so each release rebuilt every toolchain layer even when nothing in them changed. Between 2.11.10 and 2.11.11, 1,028 MB of the 1,049 MB compressed amd64 image was new layers, so every upgrade was close to a full pull. The release ARGs are now declared just before the CLI install, and a unit test keeps them there. The image also drops payload nothing runs: - rustup's minimal profile replaces the default one, which added offline HTML docs, rustfmt and clippy. Reachability runs only cargo. - npm and pip caches are no longer left in the image. - strace is no longer installed. - A .dockerignore keeps tests and other repo files out of the build context. dist/ stays in, since Dockerfile.preview copies the preview wheel from it. --- .dockerignore | 20 +++++++++++ .github/workflows/python-tests.yml | 1 + CHANGELOG.md | 13 +++++++ Dockerfile | 37 +++++++++++-------- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- tests/unit/test_dockerfile.py | 58 ++++++++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 .dockerignore create mode 100644 tests/unit/test_dockerfile.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de2daac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# The image needs only the package source, its build metadata and the entrypoint +# script. Keep dist/: Dockerfile.preview copies the preview wheel from it. +.git +.github +.context +.venv +.venv-test +venv +**/__pycache__ +**/*.pyc +.pytest_cache +.coverage* +htmlcov +tests +benchmarks +docs +examples +instructions +workflows +session.md diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1a681e1..bfde20a 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -13,6 +13,7 @@ on: - "tests/core/**/*.py" - "pyproject.toml" - "uv.lock" + - "Dockerfile" - ".github/workflows/python-tests.yml" # Unfiltered: a required check behind a paths filter never reports on a # pull request that misses the filter, which blocks the merge. diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe78cc..1efdc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.11.12 + +### Changed: smaller Docker image, and upgrades reuse its toolchain layers + +- Upgrading `socketdev/cli` from one release to the next now downloads only the + layers that changed. Previously each release rebuilt every layer, so an upgrade + downloaded nearly the whole image. +- Rust is installed with rustup's minimal profile (`rustc`, `cargo`, `rust-std`). + The image no longer includes Rust's offline HTML documentation, `rustfmt`, or + `clippy`. +- Removed `strace` from the image, and the npm and pip download caches. Run + `apk add strace` in a derived image if you need it. + ## 2.11.11 ### Changed: use lowercase Python CLI User-Agent diff --git a/Dockerfile b/Dockerfile index 4450c6a..23654b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,20 +11,13 @@ ARG GO_VERSION=system ARG JAVA_VERSION=17 ARG DOTNET_VERSION=8 -# CLI and SDK arguments -ARG CLI_VERSION -ARG SDK_VERSION -ARG PIP_INDEX_URL=https://pypi.org/simple -ARG PIP_EXTRA_INDEX_URL=https://pypi.org/simple -ARG USE_LOCAL_INSTALL=false - # Install base packages first # ca-certificates must be 20261020-r0 or newer, which drops the roots the # compromised eMudhra intermediates chain to. The base image ships an older # one, and `apk add` does not upgrade installed packages, so upgrade it here. -RUN apk update && apk add --no-cache \ +RUN apk add --no-cache \ git curl wget \ - ruby ruby-dev build-base strace && \ + ruby ruby-dev build-base && \ apk upgrade --no-cache ca-certificates && \ [ "$(apk version -t "$(apk list -I ca-certificates | awk '{print $1}' | sed 's/^ca-certificates-//')" 20261020-r0)" != "<" ] @@ -104,11 +97,14 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ fi # Install additional tools +# Reachability only runs cargo, so install the minimal Rust profile (rustc, +# cargo, rust-std). The default profile adds about 900 MB of offline HTML docs. RUN npm install socket -g && \ + npm cache clean --force && \ gem install bundler && \ - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && \ . ~/.cargo/env && \ - rustup component add rustfmt clippy + cargo --version # Set environment paths ENV PATH="/usr/local/go/bin:/usr/lib/go/bin:/root/.cargo/bin:${PATH}" @@ -148,10 +144,21 @@ COPY pyproject.toml uv.lock /tmp/socket-cli-lock/ # at TestPyPI don't leak into the locked dependency install via pip's env vars. RUN uv export --directory /tmp/socket-cli-lock --frozen --no-dev --no-emit-project \ --format requirements-txt -o /tmp/socket-cli-lock/requirements.txt && \ - pip install --require-hashes --no-deps \ + pip install --no-cache-dir --require-hashes --no-deps \ --index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple \ -r /tmp/socket-cli-lock/requirements.txt +# CLI and SDK arguments +# Declared here rather than at the top: every RUN after an ARG is cache-keyed on +# its value, and CLI_VERSION changes on every release. Declared earlier, it +# would rebuild every toolchain layer above, so each release would be a full +# re-pull for anyone upgrading. +ARG CLI_VERSION +ARG SDK_VERSION +ARG PIP_INDEX_URL=https://pypi.org/simple +ARG PIP_EXTRA_INDEX_URL=https://pypi.org/simple +ARG USE_LOCAL_INSTALL=false + # Install CLI based on build mode RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Using local development install"; \ @@ -159,7 +166,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ cli_installed=false; \ for i in $(seq 1 10); do \ echo "Attempt $i/10: Installing socketsecurity==$CLI_VERSION"; \ - if pip install --no-deps --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ + if pip install --no-cache-dir --no-deps --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ cli_installed=true; \ break; \ fi; \ @@ -174,7 +181,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ fi; \ pip check || exit 1; \ if [ ! -z "$SDK_VERSION" ]; then \ - pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ + pip install --no-cache-dir --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ fi; \ fi @@ -182,7 +189,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ COPY . /app WORKDIR /app RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ - pip install --no-deps -e . && pip check; \ + pip install --no-cache-dir --no-deps -e . && pip check; \ fi # Create workspace directory with proper permissions diff --git a/pyproject.toml b/pyproject.toml index 966fee1..183008b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.11.11" +version = "2.11.12" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 4a07834..590de20 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,4 +1,4 @@ __author__ = 'socket.dev' -__version__ = '2.11.11' +__version__ = '2.11.12' USER_AGENT_NAME = 'socket-python-cli' USER_AGENT = f'{USER_AGENT_NAME}/{__version__}' diff --git a/tests/unit/test_dockerfile.py b/tests/unit/test_dockerfile.py new file mode 100644 index 0000000..0fd46c1 --- /dev/null +++ b/tests/unit/test_dockerfile.py @@ -0,0 +1,58 @@ +"""Guards the Dockerfile's layer order, which decides how much an image upgrade downloads. + +Every RUN after an ARG is cache-keyed on that ARG's value, and CLI_VERSION changes on every +release. Declared above the toolchain layers, it rebuilds all of them on every release, so +anyone upgrading re-pulls the whole image. Nothing fails when that happens, so check it here. +""" + +from pathlib import Path + +DOCKERFILE = Path(__file__).resolve().parents[2] / "Dockerfile" +RELEASE_ARGS = { + "CLI_VERSION", + "SDK_VERSION", + "PIP_INDEX_URL", + "PIP_EXTRA_INDEX_URL", + "USE_LOCAL_INSTALL", +} +TOOLCHAIN_MARKERS = ("openjdk", "dotnet", "pypy", "rustup", "pyenv") + + +def _instructions() -> list[str]: + """The Dockerfile's instructions, with continuation lines joined and comments dropped.""" + instructions: list[str] = [] + current = "" + for line in DOCKERFILE.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + current += " " + stripped.removesuffix("\\") + if not stripped.endswith("\\"): + instructions.append(current.strip()) + current = "" + return instructions + + +def test_release_args_are_declared_after_the_toolchain_layers(): + instructions = _instructions() + release_args = [ + i + for i, instruction in enumerate(instructions) + if instruction.startswith("ARG ") + and instruction.split()[1].split("=")[0] in RELEASE_ARGS + ] + toolchain_layers = [ + i + for i, instruction in enumerate(instructions) + if instruction.startswith("RUN ") + and any(marker in instruction for marker in TOOLCHAIN_MARKERS) + ] + assert len(release_args) == len(RELEASE_ARGS), ( + "Dockerfile no longer declares every release ARG" + ) + assert len(toolchain_layers) >= len(TOOLCHAIN_MARKERS), ( + "Dockerfile no longer installs every toolchain" + ) + assert min(release_args) > max(toolchain_layers), ( + "A release ARG is declared above a toolchain layer, so every release would rebuild it" + ) diff --git a/uv.lock b/uv.lock index 0a14aad..f9e7a03 100644 --- a/uv.lock +++ b/uv.lock @@ -1332,7 +1332,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.11.11" +version = "2.11.12" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },