diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c157ceb --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,190 @@ +name: main + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + +jobs: + docker: + strategy: + fail-fast: false + matrix: + repository: + - 'ghcr.io' + - 'docker.io' + python: + - '3.12' + # - '3.11' + # - '3.10' + # - '3.9' + # - '3.8' + alpine: + - '3.20' + os: + - 'ubuntu-latest' + + runs-on: ${{ matrix.os }} + permissions: + packages: write + + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + id: image_env + run: | + . ./env.sh \ + '${{ matrix.alpine }}' \ + '${{ matrix.python }}' \ + '${{ github.repository_owner }}' \ + '${{ matrix.repository }}' + + docker pull "${SOURCE_IMAGE}" + + echo ALPINE_VERSION="${ALPINE_VERSION}" >> "$GITHUB_OUTPUT" + echo PYTHON_VERSION="${PYTHON_VERSION}" >> "$GITHUB_OUTPUT" + echo SOURCE_IMAGE="${SOURCE_IMAGE}" >> "$GITHUB_OUTPUT" + echo IMAGE_TAG="${IMAGE_TAG}" >> "$GITHUB_OUTPUT" + echo REPOSITORY="${REPOSITORY}" >> "$GITHUB_OUTPUT" + echo BASE_IMAGE_DIGEST="$(digest_of "$SOURCE_IMAGE")" >> "$GITHUB_OUTPUT" + + - + name: Buildroot + uses: docker/build-push-action@v6 + with: + platforms: | + linux/amd64 + linux/arm64 + context: "." + file: Dockerfile.alpine + target: buildroot + cache-from: | + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot + type=registry,ref=docker.io/python@${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + build-args: | + ALPINE_VERSION=${{ steps.image_env.outputs.ALPINE_VERSION }} + BASE_IMAGE_DIGEST=${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + PYTHON_VERSION=${{ steps.image_env.outputs.PYTHON_VERSION }} + SOURCE_IMAGE=${{ steps.image_env.outputs.SOURCE_IMAGE }} + BUILD_ROOT=/d + tags: "${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot" + - + name: distroless + uses: docker/build-push-action@v6 + with: + platforms: | + linux/amd64 + linux/arm64 + context: "." + file: Dockerfile.alpine + # target: distroless-python + cache-from: | + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }} + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot + type=registry,ref=docker.io/python@${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + build-args: | + ALPINE_VERSION=${{ steps.image_env.outputs.ALPINE_VERSION }} + BASE_IMAGE_DIGEST=${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + PYTHON_VERSION=${{ steps.image_env.outputs.PYTHON_VERSION }} + SOURCE_IMAGE=${{ steps.image_env.outputs.SOURCE_IMAGE }} + BUILD_ROOT=/d + tags: "${{ steps.image_env.outputs.IMAGE_TAG }}" + # - + # name: distroless-tests + # uses: docker/build-push-action@v6 + # with: + # context: "." + # platforms: | + # linux/amd64 + # linux/arm64 + # file: Dockerfile.alpine + # target: tests + # cache-from: | + # type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }} + # type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot + # type=registry,ref=docker.io/python@${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + # build-args: | + # ALPINE_VERSION=${{ steps.image_env.outputs.ALPINE_VERSION }} + # BASE_IMAGE_DIGEST=${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + # PYTHON_VERSION=${{ steps.image_env.outputs.PYTHON_VERSION }} + # SOURCE_IMAGE=${{ steps.image_env.outputs.SOURCE_IMAGE }} + # BUILD_ROOT=/d + # tags: "${{ steps.image_env.outputs.IMAGE_TAG }}-test" + - + name: export annotations + id: inspect + run: | + echo 'annotations<> "$GITHUB_OUTPUT" + docker inspect '${{ steps.image_env.outputs.IMAGE_TAG }}' | jq -r '.[].Config.Labels| keys[] as $k | "\($k)=\(.[$k])"' >> "$GITHUB_OUTPUT" + echo 'EOF' >> "$GITHUB_OUTPUT" + + - + name: Login to GitHub Container Registry + if: ${{ matrix.repository == 'ghcr.io' }} + uses: docker/login-action@v3 + with: + registry: 'ghcr.io' + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Login to DockerHub + if: ${{ matrix.repository == 'docker.io' }} + uses: docker/login-action@v3 + with: + registry: 'docker.io' + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - + name: Upload Buildroot + uses: docker/build-push-action@v6 + with: + push: true + platforms: | + linux/amd64 + linux/arm64 + context: "." + file: Dockerfile.alpine + target: buildroot + cache-from: | + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot + type=registry,ref=docker.io/python@${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + build-args: | + ALPINE_VERSION=${{ steps.image_env.outputs.ALPINE_VERSION }} + BASE_IMAGE_DIGEST=${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + PYTHON_VERSION=${{ steps.image_env.outputs.PYTHON_VERSION }} + SOURCE_IMAGE=${{ steps.image_env.outputs.SOURCE_IMAGE }} + BUILD_ROOT=/d + tags: "${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot" + + - + name: Upload + uses: docker/build-push-action@v6 + with: + push: true + context: "." + platforms: | + linux/amd64 + linux/arm64 + file: Dockerfile.alpine + cache-from: | + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }} + type=registry,ref=${{ steps.image_env.outputs.IMAGE_TAG }}-buildroot + type=registry,ref=docker.io/python@${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + build-args: | + ALPINE_VERSION=${{ steps.image_env.outputs.ALPINE_VERSION }} + BASE_IMAGE_DIGEST=${{ steps.image_env.outputs.BASE_IMAGE_DIGEST }} + PYTHON_VERSION=${{ steps.image_env.outputs.PYTHON_VERSION }} + SOURCE_IMAGE=${{ steps.image_env.outputs.SOURCE_IMAGE }} + BUILD_ROOT=/d + tags: "${{ steps.image_env.outputs.IMAGE_TAG }}" + labels: ${{steps.image_env.outputs.IMAGE_LABELS}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98789e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.egg-info/ +.idea/* +*.idx +*.sublime-* +*.pyc +dist/ +.cache/ +*.dat +.DS_Store +python/ +.pytest_cache/ +build/* +.pytype/* +instruct/about.py +python*/ +.coverage diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 0000000..178fb31 --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,104 @@ +ARG ALPINE_VERSION=3.20 +ARG PYTHON_VERSION=3.12 +ARG SOURCE_IMAGE=docker.io/python:${PYTHON_VERSION}-alpine${ALPINE_VERSION} + +FROM --platform=$BUILDPLATFORM $SOURCE_IMAGE AS buildroot +ARG PYTHON_VERSION=3.12 + +ARG BUILD_ROOT='/dest' +ENV BUILD_ROOT=$BUILD_ROOT \ + PYTHON_VERSION=$PYTHON_VERSION \ + _sys_apk_add="/usr/bin/env apk add --no-cache" \ + _apk_add="/usr/bin/env apk add --root $BUILD_ROOT --no-cache" \ + _apk_del="/usr/bin/env apk del --root $BUILD_ROOT --purge" \ + _sh="chroot $BUILD_ROOT sh" \ + _ln="chroot $BUILD_ROOT ln" + +RUN set -eu ; \ + # Add to buildroot: + $_sys_apk_add \ + # dash is used as a /bin/sh replacement + dash \ + # TLS certs + ca-certificates \ + # zip is used to take all the bytecode compiled standard + # library and create a pythonXY.zip file that will + # be imported from. This makes the stdlib immutable. + zip \ + ; \ + # remove all ``__pycache__`` directories + find /usr/local/lib/python$PYTHON_VERSION -type d -name '__pycache__' -print0 | xargs -0 rm -rf ; \ + # compile all py to an adjacent pyc and remove the original, leaving only the bytecode + python -m compileall -b /usr/local/lib/python$PYTHON_VERSION ; \ + find -type f -name '*.py' -exec sh -c "[ -f \"{}c\" ] && echo 'Removing \"{}\"' && rm -f \"{}\"" \; ;\ + # make the new root: + mkdir -p \ + $BUILD_ROOT/etc \ + $BUILD_ROOT/bin \ + $BUILD_ROOT/usr/local/lib/python$PYTHON_VERSION/site-packages \ + $BUILD_ROOT/usr/local/bin \ + ; \ + # use a symlink to hold the apk related confs + ln -s /etc/apk $BUILD_ROOT/etc/apk ; \ + $_apk_add --initdb ; \ + $_apk_add \ + alpine-baselayout-data \ + alpine-release \ + musl \ + libffi \ + # needed for update-ca-certificates to work: + run-parts \ + # install the runtime dependencies for python + $(apk info -R .python-rundeps | grep -vE ':$') \ + ; \ + cp -p /bin/busybox $BUILD_ROOT/bin/busybox ; \ + ls -lt $BUILD_ROOT/bin/busybox ; \ + chroot $BUILD_ROOT /bin/busybox ln -sf /bin/busybox /bin/ln ; \ + # copy dash into the container so we can use it as the default bin/sh + tar -C / -cpf - $(\ + apk info -L \ + dash \ + ca-certificates \ + | grep -vE ':$' \ + ) | tar -C $BUILD_ROOT -xpf - ; \ + $_ln -sf /usr/bin/dash /bin/sh ; \ + (\ + cd /usr/local/lib && \ + tar -C /usr/local/lib -cpf - python$PYTHON_VERSION/lib-dynload libpython* | tar -C $BUILD_ROOT/usr/local/lib -xpf - ; \ + tar -C /usr/local/bin -cpf - python* | tar -C $BUILD_ROOT/usr/local/bin -xpf -; \ + (cd python$PYTHON_VERSION && zip -9 -X $BUILD_ROOT/usr/local/lib/python$(echo $PYTHON_VERSION | tr -d '.').zip $(\ + find . | grep -vE "(__pycache__|^\./(test|site-packages|lib-dynload|idlelib|lib2to3|tkinter|turtle|ensurepip|pydoc))" \ + )); \ + cp -p python$PYTHON_VERSION/os.pyc $BUILD_ROOT/usr/local/lib/python$PYTHON_VERSION/os.pyc ; \ + touch $BUILD_ROOT/usr/local/lib/python$PYTHON_VERSION/ensurepip.py ; \ + rm $BUILD_ROOT/usr/local/lib/python$PYTHON_VERSION/lib-dynload/_tkinter* ; \ + ) && \ + $_ln -sf /usr/local/bin/python$PYTHON_VERSION /usr/local/bin/python3 && \ + $_ln -sf /usr/local/bin/python$PYTHON_VERSION /usr/local/bin/python && \ + rm $BUILD_ROOT/bin/ln $BUILD_ROOT/bin/busybox $BUILD_ROOT/etc/apk && \ + rm -rf $BUILD_ROOT/var/cache/apk /usr/share/apk && \ + # regenerate the ca-certs! + chroot $BUILD_ROOT update-ca-certificates + + + +FROM scratch AS distroless-python +ARG ALPINE_VERSION=3.20 +ARG PYTHON_VERSION=3.12 +ARG SOURCE_IMAGE=docker.io/python:${PYTHON_VERSION}-alpine${ALPINE_VERSION} +ARG BASE_IMAGE_DIGEST +ARG BUILD_ROOT='/dest' + +COPY --from=buildroot $BUILD_ROOT / +LABEL \ + org.opencontainers.image.authors="distroless-python image developers " \ + org.opencontainers.image.source="https://github.com/autumnjolitz/distroless-python" \ + org.opencontainers.image.title="Distroless Python ${PYTHON_VERSION} on alpine${ALPINE_VERSION}" \ + org.opencontainers.image.description="Distroless, optimized Python images distilled from the DockerHub official Python images. These images only have a python interpreter and the dash shell." \ + org.opencontainers.image.base.digest="${BASE_IMAGE_DIGEST}" \ + org.opencontainers.image.base.name="$SOURCE_IMAGE" \ + distroless.python-version="${PYTHON_VERSION}" \ + distroless.alpine-version="${ALPINE_VERSION}" \ + distroless.base-image="alpine${ALPINE_VERSION}" + +ENTRYPOINT [ "/usr/local/bin/python" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41b98ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2024, Autumn Jolitz +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the distroless-python project nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL DISTROLESS-PYTHON PROJECT CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d4de6eb --- /dev/null +++ b/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh + +. env.sh + +log="$(mktemp image.log.XXXX)" +trap "rm -f $log" EXIT + +if ! >"$log" 2>&1 docker pull "$SOURCE_IMAGE"; then + >&2 echo 'Unable to find '"$SOURCE_IMAGE"'!' + >&2 cat "$log" + exit 4; +fi + + +BASE_IMAGE_DIGEST=$(digest_of "$SOURCE_IMAGE" "$log") +if [ "x$BASE_IMAGE_DIGEST" = 'x' ]; then + exit 88 +fi + +>&2 echo "BASE_IMAGE_DIGEST=${BASE_IMAGE_DIGEST}" + +if ! >"$log" 2>&1 docker build \ + --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" \ + --build-arg "BASE_IMAGE_DIGEST=${BASE_IMAGE_DIGEST}" \ + --build-arg "PYTHON_VERSION=${PYTHON_VERSION}" \ + --build-arg "BUILD_ROOT=/d" \ + -f Dockerfile.alpine \ + -t "$IMAGE_TAG" \ + . ; then + >&2 echo 'Unable to build '"$IMAGE_TAG"'!' + >&2 cat "$log" + exit 8; +fi + +echo "$IMAGE_TAG" diff --git a/env.sh b/env.sh new file mode 100644 index 0000000..034f058 --- /dev/null +++ b/env.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env sh + +set -eu +set -o pipefail +shopt -s nullglob + +trim_slash() { + local s + for s in "${@:-}" + do + sed -E 's://*:/:g; s:(^/)?/*$:\1:' <<< "${s}" + done; +} + +ALPINE_VERSION="${1:-}" +PYTHON_VERSION="${2:-}" +ORG="${3:-}" + +if [ "x${ALPINE_VERSION}" = 'x' ]; then + >&2 echo 'missing ALPINE_VERSION' + exit 1 +fi + +if [ "x${PYTHON_VERSION}" = 'x' ]; then + >&2 echo 'missing PYTHON_VERSION' + exit 1 +fi + +REPOSITORY="${4:-}" + +if ! case $REPOSITORY in */) false ;; *) true ;; esac; then + # trim off the trailing slash + REPOSITORY="$(trim_slash "$REPOSITORY")" +fi + +if ! case $REPOSITORY in docker.io*) false ;; *) true ;; esac; then + # ARJ: The default context _is_ docker.io + REPOSITORY='' +fi + +IMAGE_TAG="distroless-python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}" +if [ "x$ORG" != 'x' ]; then + IMAGE_TAG="${ORG}/${IMAGE_TAG}" +fi + +if [ "x$REPOSITORY" != 'x' ]; then + IMAGE_TAG="${REPOSITORY}/${IMAGE_TAG}" +fi + +SOURCE_IMAGE="docker.io/python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}" + +jq="$(command -v jq)" + +digest_of() { + local repo="${1:-}" + local log="${2:-}" + if [ "x$jq" = 'x' ]; then + >&2 echo 'jq not installed!' + return 101 + fi + if [ "x$repo" = 'x' ]; then + >&2 echo 'no repo provided!' + return 1 + fi + local workfile="$(mktemp digest_of.workfile.XXXX)" + if [ "x$log" = 'x' ]; then + log="$(mktemp digest_of.log.XXXX)" + trap "rm -f $log $workfile" EXIT + else + trap "rm -f $workfile" EXIT + fi + if ! 2>>"$log" docker inspect "$repo" | 2>>"$log" >"$workfile" $jq -r '.[].RepoDigests[]'; then + >&2 echo 'Unable to inspect '"$repo"'!' + >&2 cat "$log" + return 16 + fi + local digest=$(< "$workfile") + if [ "x$digest" = 'x' ]; then + >&2 echo 'digest empty?!' + return 88 + fi + echo "$digest" +} + +>&2 echo "ALPINE_VERSION=${ALPINE_VERSION}" +>&2 echo "PYTHON_VERSION=${PYTHON_VERSION}" +>&2 echo "SOURCE_IMAGE=${SOURCE_IMAGE}" +>&2 echo "IMAGE_TAG=${IMAGE_TAG}" +>&2 echo "REPOSITORY=${REPOSITORY}" + +export ALPINE_VERSION +export PYTHON_VERSION +export SOURCE_IMAGE +export IMAGE_TAG +export REPOSITORY