diff --git a/README.md b/README.md index e738955..a5fb6db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ The `docker-image-extract` script pulls and extracts all files from an image -in [Docker Hub](https://hub.docker.com/). For multi-platform images, you can -choose the platform-specific image to pull. +in [Docker Hub](https://hub.docker.com/) or any OCI-compliant registry +(e.g. [GHCR](https://ghcr.io), [Quay](https://quay.io), +[GitLab Registry](https://docs.gitlab.com/ee/user/packages/container_registry/)). +For multi-platform images, you can choose the platform-specific image to pull. `docker-image-extract` has minimal dependencies that should exist in pretty much any Linux environment, with the possible exception of `curl`/`wget`: @@ -17,15 +19,16 @@ much any Linux environment, with the possible exception of `curl`/`wget`: If you are using [BusyBox](https://busybox.net/), version 1.34.0 or later is required, as the `wget` implementation in earlier versions does not recognize -the HTTP 307/308 redirects returned by Docker Hub. +the HTTP 307/308 redirects returned by some registries. ## Usage ``` ./docker-image-extract [OPTIONS...] IMAGE[:REF] -IMAGE can be a community user image (like 'some-user/some-image') or a -Docker official image (like 'hello-world', which contains no '/'). +IMAGE can be: + - A Docker Hub image: 'hello-world', 'some-user/some-image' + - An OCI registry image: 'ghcr.io/user/image', 'quay.io/user/image' REF is either a tag name or a full SHA-256 image digest (with a 'sha256:' prefix). The default ref is the 'latest' tag. @@ -33,8 +36,6 @@ The default ref is the 'latest' tag. Options: -p PLATFORM Pull image for the specified platform (default: linux/amd64) - For a given image on Docker Hub, the 'Tags' tab lists the - platforms supported for that image. -o OUT_DIR Extract image to the specified output dir (default: ./output) -h Show help with usage examples @@ -46,13 +47,28 @@ $ ./docker-image-extract hello-world:latest # Same as above; ref defaults to the 'latest' tag. $ ./docker-image-extract hello-world -# Pull the 'hello-world' image for the 'linux/arm64/v8' platform. +# Pull from GitHub Container Registry. +$ ./docker-image-extract ghcr.io/user/image:latest + +# Pull the image for the 'linux/arm64/v8' platform. $ ./docker-image-extract -p linux/arm64/v8 hello-world # Pull an image by digest. $ ./docker-image-extract hello-world:sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 ``` +## OCI registry support + +For non-Docker Hub registries, the script dynamically discovers the +authentication endpoint by probing `GET /v2/` and parsing the +`WWW-Authenticate: Bearer realm="...",service="..."` header returned on a 401 +response (per the [OCI Distribution Spec](https://github.com/opencontainers/distribution-spec)). +Registries that serve public images without authentication (e.g. +`mcr.microsoft.com`) are also supported. + +Tested against: `docker.io`, `ghcr.io`, `quay.io`, `registry.gitlab.com`, +`mcr.microsoft.com`. + ## Sample output ``` diff --git a/docker-image-extract b/docker-image-extract index c51bd88..d27ddb1 100755 --- a/docker-image-extract +++ b/docker-image-extract @@ -1,8 +1,9 @@ #!/bin/sh # -# This script pulls and extracts all files from an image in Docker Hub. +# This script pulls and extracts all files from an image in Docker Hub or any +# OCI-compliant registry (ghcr.io, quay.io, registry.gitlab.com, etc.). # -# Copyright (c) 2020-2023, Jeremy Lin +# Copyright (c) 2020-2024, Jeremy Lin # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -27,12 +28,13 @@ PLATFORM="${PLATFORM_DEFAULT}" OUT_DIR="./output" usage() { - echo "This script pulls and extracts all files from an image in Docker Hub." + echo "This script pulls and extracts all files from an image in Docker Hub or any OCI registry." echo echo "$0 [OPTIONS...] IMAGE[:REF]" echo - echo "IMAGE can be a community user image (like 'some-user/some-image') or a" - echo "Docker official image (like 'hello-world', which contains no '/')." + echo "IMAGE can be:" + echo " - A Docker Hub image: 'hello-world', 'some-user/some-image'" + echo " - An OCI registry image: 'ghcr.io/user/image', 'quay.io/user/image'" echo echo "REF is either a tag name or a full SHA-256 image digest (with a 'sha256:' prefix)." echo "The default ref is the 'latest' tag." @@ -40,8 +42,6 @@ usage() { echo "Options:" echo echo " -p PLATFORM Pull image for the specified platform (default: ${PLATFORM})" - echo " For a given image on Docker Hub, the 'Tags' tab lists the" - echo " platforms supported for that image." echo " -o OUT_DIR Extract image to the specified output dir (default: ${OUT_DIR})" echo " -h Show help with usage examples" } @@ -51,14 +51,14 @@ usage_detailed() { echo echo "Examples:" echo - echo "# Pull and extract all files in the 'hello-world' image tagged 'latest'." + echo "# Pull from Docker Hub." echo "\$ $0 hello-world:latest" echo - echo "# Same as above; ref defaults to the 'latest' tag." - echo "\$ $0 hello-world" + echo "# Pull from GitHub Container Registry." + echo "\$ $0 ghcr.io/user/image:latest" echo - echo "# Pull the 'hello-world' image for the 'linux/arm64/v8' platform." - echo "\$ $0 -p linux/arm64/v8 hello-world" + echo "# Pull for a specific platform." + echo "\$ $0 -p linux/arm64/v8 ghcr.io/user/image" echo echo "# Pull an image by digest." echo "\$ $0 hello-world:sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042" @@ -127,35 +127,11 @@ if ! have_curl && ! have_wget; then exit 1 fi -image_spec="$1" -image="${image_spec%%:*}" -if [ "${image#*/}" = "${image}" ]; then - # Docker official images are in the 'library' namespace. - image="library/${image}" -fi -ref="${image_spec#*:}" -if [ "${ref}" = "${image_spec}" ]; then - echo "Defaulting ref to tag 'latest'..." - ref=latest -fi - -# Split platform (OS/arch/variant) into separate variables. -# A platform specifier doesn't always include the `variant` component. -OLD_IFS="${IFS}" -IFS=/ read -r OS ARCH VARIANT <":"" (assumes key/val won't contain double quotes). - # The colon may have whitespace on either side. grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]\+\"" | - # Extract just by deleting the last '"', and then greedily deleting - # everything up to '"'. sed -e 's/"$//' -e 's/.*"//' } @@ -181,108 +157,277 @@ fetch() { fi } -# https://docs.docker.com/docker-hub/api/latest/#tag/repositories -manifest_list_url="https://hub.docker.com/v2/repositories/${image}/tags/${ref}" +# Fetch only the response headers for a URL (GET request, body discarded). +# HEAD (-I) is avoided because some registries (e.g., ghcr.io) return 405 on HEAD. +fetch_headers() { + local url="$1" + if have_curl; then + curl -sSL -D - -o /dev/null "${url}" 2>/dev/null + else + wget -qS -O /dev/null "${url}" 2>&1 + fi +} + +# ============================================================ +# REGISTRY DETECTION +# ============================================================ +# +# A registry prefix is detected when the first path component of the image +# spec contains a dot (e.g., ghcr.io, quay.io, registry.gitlab.com). +# Docker Hub shorthand images (hello-world, user/image) have no dot. + +image_spec="$1" +_first_component="${image_spec%%/*}" +_first_no_port="${_first_component%%:*}" -# If the ref is already a SHA-256 image digest, then we don't need to look up anything. -if [ -z "${ref##sha256:*}" ]; then - digest="${ref}" +if [ "${_first_component}" != "${image_spec}" ] && echo "${_first_no_port}" | grep -q '\.'; then + # Non-Docker Hub OCI registry (e.g., ghcr.io/user/image:tag) + registry="${_first_no_port}" + _rest="${image_spec#*/}" # "user/image:tag" + image="${_rest%%:*}" # "user/image" + _ref_candidate="${_rest#*:}" + if [ "${_ref_candidate}" = "${_rest}" ]; then + echo "Defaulting ref to tag 'latest'..." + ref="latest" + else + ref="${_ref_candidate}" + fi else - echo "Getting multi-arch manifest list..." - NL=' -' - digest=$(fetch "${manifest_list_url}" | - # Break up the single-line JSON output into separate lines by adding - # newlines before and after the chars '[', ']', '{', and '}'. - # This uses the \${NL} syntax because some BSD variants of sed don't - # support \n syntax in the replacement string, but instead require - # a literal newline preceded by a backslash. - sed -e 's/\([][{}]\)/\'"${NL}"'\1\'"${NL}"'/g' | - # Extract the "images":[...] list. - sed -n '/"images":/,/]/ p' | - # Each image's details are now on a separate line, e.g. - # "architecture":"arm64","features":"","variant":"v8","digest":"sha256:054c85801c4cb41511b176eb0bf13a2c4bbd41611ddd70594ec3315e88813524","os":"linux","os_features":"","os_version":null,"size":828724,"status":"active","last_pulled":"2022-09-02T22:46:48.240632Z","last_pushed":"2022-09-02T00:42:45.69226Z" - # The image details are interspersed with lines of stray punctuation, - # so grep for an arbitrary string that must be in these lines. - grep architecture | - # Search for an image that matches the platform. - while read -r image; do - # Arch is probably most likely to be unique, so check that first. - arch="$(echo ${image} | extract 'architecture')" - if [ "${arch}" != "${ARCH}" ]; then continue; fi - - os="$(echo ${image} | extract 'os')" - if [ "${os}" != "${OS}" ]; then continue; fi - - variant="$(echo ${image} | extract 'variant')" - if [ "${variant}" = "${VARIANT}" ]; then - echo ${image} | extract 'digest' - break - fi - done) + # Docker Hub + registry="" + image="${image_spec%%:*}" + if [ "${image#*/}" = "${image}" ]; then + # Docker official images are in the 'library' namespace. + image="library/${image}" + fi + ref="${image_spec#*:}" + if [ "${ref}" = "${image_spec}" ]; then + echo "Defaulting ref to tag 'latest'..." + ref=latest + fi +fi - if [ -n "${digest}" ]; then - echo "Platform ${PLATFORM} resolved to '${digest}'..." +# Split platform (OS/arch/variant) into separate variables. +OLD_IFS="${IFS}" +IFS=/ read -r OS ARCH VARIANT <