diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index 8b72f18..cf1f76d 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -9,6 +9,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Check install-php-extensions + run: bash tests/install-php-extensions.sh - name: Features uses: devcontainers/action@v1 with: diff --git a/features/install-php-extensions/README.md b/features/install-php-extensions/README.md new file mode 100644 index 0000000..5bec7be --- /dev/null +++ b/features/install-php-extensions/README.md @@ -0,0 +1,38 @@ +# Install PHP extensions + +Installs PHP extensions in a development container. + +## Supported images + +Official Docker PHP images from https://hub.docker.com/_/php include `docker-php-ext-install`, `docker-php-ext-configure`, `docker-php-ext-enable`, and `docker-php-source`. On those images this feature downloads [mlocati/docker-php-extension-installer](https://github.com/mlocati/docker-php-extension-installer) 2.2.5 and runs it. + +Debian and Ubuntu images, including `mcr.microsoft.com/devcontainers/base`, do not include those commands. On those images this feature installs `php-cli` and the distro package for each extension. `zip` installs `php-zip`. Apt repositories from the image choose the PHP version. + +Any other image stops before that installer is downloaded. The error names both supported bases. This feature does not add fake `docker-php-ext-*` commands to look like an official PHP image. + +## Options + +`extensions` is a string of extension names separated by spaces or commas. Example: `zip` or `zip, gd`. + +## Example + +```json +{ + "image": "mcr.microsoft.com/devcontainers/base:bullseye", + "features": { + "ghcr.io/opencodeco/devcontainers/install-php-extensions": { + "extensions": "zip" + } + } +} +``` + +## Checks + +From the repository root: + +```bash +bash tests/install-php-extensions.sh +``` + +That check does not build an image and does not install packages. diff --git a/features/install-php-extensions/devcontainer-feature.json b/features/install-php-extensions/devcontainer-feature.json index 21d2bf4..c9042ee 100644 --- a/features/install-php-extensions/devcontainer-feature.json +++ b/features/install-php-extensions/devcontainer-feature.json @@ -1,11 +1,15 @@ { "id": "install-php-extensions", - "version": "0.1.225", - "name": "install-php-extensions", + "version": "0.1.226", + "name": "Install PHP extensions", + "description": "Install PHP extensions on official PHP images, or from apt on Debian and Ubuntu images such as mcr.microsoft.com/devcontainers/base.", + "documentationURL": "https://github.com/opencodeco/devcontainers/tree/main/features/install-php-extensions", "options": { "extensions": { "type": "string", - "default": "" + "proposals": ["zip", "gd", "xdebug"], + "default": "", + "description": "Extension names separated by spaces or commas. Example: zip" } } } diff --git a/features/install-php-extensions/install.sh b/features/install-php-extensions/install.sh index 02cac8a..26d84d4 100644 --- a/features/install-php-extensions/install.sh +++ b/features/install-php-extensions/install.sh @@ -1,7 +1,152 @@ #!/usr/bin/env bash -set -e -curl -sSLf \ - -o /usr/local/bin/install-php-extensions \ - https://github.com/mlocati/docker-php-extension-installer/releases/download/2.2.5/install-php-extensions -chmod +x /usr/local/bin/install-php-extensions -install-php-extensions "$EXTENSIONS" +# Install PHP extensions for a dev container feature. +# +# Official Docker PHP images provide docker-php-ext-install and related +# helpers. On those images this script runs mlocati/docker-php-extension-installer. +# Debian and Ubuntu images, including mcr.microsoft.com/devcontainers/base, +# do not provide those helpers. On those images this script installs php-cli +# and the distro package for each extension. zip becomes php-zip. +# It does not create fake helper commands to look like an official PHP image. +set -euo pipefail + +MLOCATI_VERSION="2.2.5" +MLOCATI_URL="https://github.com/mlocati/docker-php-extension-installer/releases/download/${MLOCATI_VERSION}/install-php-extensions" + +has_official_php_helpers() { + command -v docker-php-ext-configure >/dev/null 2>&1 \ + && command -v docker-php-ext-enable >/dev/null 2>&1 \ + && command -v docker-php-ext-install >/dev/null 2>&1 \ + && command -v docker-php-source >/dev/null 2>&1 +} + +is_debian_like() { + local os_release="${OS_RELEASE:-/etc/os-release}" + local detected + + [[ -r "${os_release}" ]] || return 1 + detected="$( + # shellcheck disable=SC1090 + . "${os_release}" + printf '%s' " ${ID:-} ${ID_LIKE:-} " + )" + case "${detected}" in + *" debian "*|*" ubuntu "*) return 0 ;; + *) return 1 ;; + esac +} + +parse_extensions() { + local raw="${1:-}" + local -a parts=() + local ext + + raw="${raw//,/ }" + # An empty value makes read return 1. That is an empty list, not a failure. + read -ra parts <<< "${raw}" || true + for ext in "${parts[@]}"; do + if [[ -n "${ext}" ]]; then + printf '%s\n' "${ext}" + fi + done +} + +validate_extension_name() { + local ext="$1" + + if [[ ! "${ext}" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then + echo "install-php-extensions: invalid extension name '${ext}'. Use letters, digits, underscores, or hyphens." >&2 + exit 1 + fi +} + +debian_php_package() { + local ext="${1,,}" + + case "${ext}" in + pdo_mysql|mysqli|mysqlnd) printf '%s\n' php-mysql ;; + pdo_pgsql|pgsql) printf '%s\n' php-pgsql ;; + pdo_sqlite|sqlite|sqlite3) printf '%s\n' php-sqlite3 ;; + *) printf 'php-%s\n' "${ext}" ;; + esac +} + +debian_packages_for_extensions() { + local ext + + printf '%s\n' php-cli + for ext in "$@"; do + debian_php_package "${ext}" + done +} + +install_with_mlocati() { + local -a exts=("$@") + + curl -sSLf -o /usr/local/bin/install-php-extensions "${MLOCATI_URL}" + chmod +x /usr/local/bin/install-php-extensions + if ((${#exts[@]} == 0)); then + install-php-extensions + else + install-php-extensions "${exts[@]}" + fi +} + +install_with_apt() { + local -a exts=("$@") + local -a packages=() + local pkg + + if ((${#exts[@]} == 0)); then + echo "install-php-extensions: no extensions listed, so no packages were installed." >&2 + return 0 + fi + + while IFS= read -r pkg; do + packages+=("${pkg}") + done < <(debian_packages_for_extensions "${exts[@]}") + + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends "${packages[@]}" + rm -rf /var/lib/apt/lists/* +} + +fail_unsupported_base() { + cat >&2 <<'EOF' +install-php-extensions: this image cannot install PHP extensions. + +Supported bases: +- Official Docker PHP images from https://hub.docker.com/_/php. Those images provide docker-php-ext-install, docker-php-ext-configure, docker-php-ext-enable, and docker-php-source. This feature then runs mlocati/docker-php-extension-installer. +- Debian and Ubuntu images, including mcr.microsoft.com/devcontainers/base. Those images install each extension with apt. The zip extension installs the php-zip package, and php-cli is installed with it. + +This image has neither those official PHP helper commands nor apt on Debian or Ubuntu. mlocati/docker-php-extension-installer was not downloaded or run. That script only works on official PHP images and exits with the message "meant to be used with official Docker PHP Images". +EOF + exit 1 +} + +main() { + local -a exts=() + local ext + + while IFS= read -r ext; do + [[ -n "${ext}" ]] || continue + validate_extension_name "${ext}" + exts+=("${ext}") + done < <(parse_extensions "${EXTENSIONS:-}") + + if has_official_php_helpers; then + install_with_mlocati "${exts[@]}" + return + fi + + if is_debian_like && command -v apt-get >/dev/null 2>&1; then + install_with_apt "${exts[@]}" + return + fi + + fail_unsupported_base +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main +fi diff --git a/tests/install-php-extensions.sh b/tests/install-php-extensions.sh new file mode 100644 index 0000000..0d7e3cf --- /dev/null +++ b/tests/install-php-extensions.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Host-side checks for the install-php-extensions feature. +# Does not build a dev container and does not install packages. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck disable=SC1091 +source "${ROOT}/features/install-php-extensions/install.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq() { + local actual="$1" + local expected="$2" + local label="$3" + + if [[ "${actual}" != "${expected}" ]]; then + fail "${label}: expected '${expected}', got '${actual}'" + fi +} + +assert_eq "$(debian_php_package zip)" "php-zip" "zip package" +assert_eq "$(debian_php_package pdo_mysql)" "php-mysql" "pdo_mysql package" +assert_eq "$(debian_php_package PDO_PGSQL)" "php-pgsql" "pdo_pgsql package" + +mapfile -t zip_packages < <(debian_packages_for_extensions zip) +assert_eq "${zip_packages[0]}" "php-cli" "apt installs php-cli" +assert_eq "${zip_packages[1]}" "php-zip" "apt installs php-zip" +assert_eq "${#zip_packages[@]}" "2" "zip package count" + +mapfile -t parsed < <(parse_extensions "zip, gd") +assert_eq "${parsed[0]}" "zip" "comma list first" +assert_eq "${parsed[1]}" "gd" "comma list second" +assert_eq "${#parsed[@]}" "2" "comma list count" + +mapfile -t parsed_spaces < <(parse_extensions "zip gd") +assert_eq "${parsed_spaces[0]}" "zip" "space list first" +assert_eq "${parsed_spaces[1]}" "gd" "space list second" + +mapfile -t parsed_empty < <(parse_extensions "") +assert_eq "${#parsed_empty[@]}" "0" "empty extension list" + +tmp="$(mktemp -d)" +cleanup() { + rm -rf "${tmp}" +} +trap cleanup EXIT + +printf 'ID=debian\n' > "${tmp}/debian" +OS_RELEASE="${tmp}/debian" is_debian_like || fail "debian os-release should match" +printf 'ID="ubuntu"\nID_LIKE=debian\n' > "${tmp}/ubuntu" +OS_RELEASE="${tmp}/ubuntu" is_debian_like || fail "ubuntu os-release should match" +printf 'ID=alpine\n' > "${tmp}/alpine" +if OS_RELEASE="${tmp}/alpine" is_debian_like; then + fail "alpine os-release should not match" +fi + +for cmd in docker-php-ext-configure docker-php-ext-enable docker-php-ext-install docker-php-source; do + printf '#!/bin/sh\nexit 0\n' > "${tmp}/${cmd}" + chmod +x "${tmp}/${cmd}" +done + +saved_path="${PATH}" +PATH="${tmp}:/usr/bin:/bin" +has_official_php_helpers || fail "all four helpers should count as an official PHP image" +rm -f "${tmp}/docker-php-source" +if has_official_php_helpers; then + fail "a missing docker-php-source should not count as an official PHP image" +fi +PATH="${saved_path}" + +# Keep these overrides in place so a routing bug cannot apt-get or curl on the host. +called="" +install_with_mlocati() { called="mlocati:$*"; } +install_with_apt() { called="apt:$*"; } + +EXTENSIONS="zip" +has_official_php_helpers() { return 0; } +is_debian_like() { return 0; } +main +assert_eq "${called}" "mlocati:zip" "official image uses mlocati" + +called="" +has_official_php_helpers() { return 1; } +is_debian_like() { return 0; } +if ! command -v apt-get >/dev/null 2>&1; then + fail "this check expects apt-get on PATH so the Debian branch can be selected" +fi +main +assert_eq "${called}" "apt:zip" "Debian base uses apt for zip" + +called="" +has_official_php_helpers() { return 1; } +is_debian_like() { return 0; } +mkdir -p "${tmp}/limited-path" +ln -s "$(command -v cat)" "${tmp}/limited-path/cat" +# This assignment is the search path on purpose. It only contains cat, so apt-get is absent. +# shellcheck disable=SC2123 +PATH="${tmp}/limited-path" +set +e +unsupported_out="$(EXTENSIONS="zip" main 2>&1)" +unsupported_rc=$? +set -e +PATH="${saved_path}" +if [[ "${unsupported_rc}" -eq 0 ]]; then + fail "debian-like image without apt-get should fail" +fi +if [[ "${unsupported_out}" != *"mlocati/docker-php-extension-installer was not downloaded or run"* ]]; then + fail "unsupported base should explain that the upstream script was not run: ${unsupported_out}" +fi +if [[ "${unsupported_out}" != *"mcr.microsoft.com/devcontainers/base"* ]]; then + fail "unsupported base should name the devcontainers base image: ${unsupported_out}" +fi +assert_eq "${called}" "" "unsupported base should not call an installer" + +called="" +has_official_php_helpers() { return 1; } +is_debian_like() { return 1; } +set +e +alpine_out="$(EXTENSIONS="zip" main 2>&1)" +alpine_rc=$? +set -e +if [[ "${alpine_rc}" -eq 0 ]]; then + fail "non-Debian image without PHP helpers should fail" +fi +if [[ "${alpine_out}" != *"meant to be used with official Docker PHP Images"* ]]; then + fail "failure should quote the upstream error before that script runs: ${alpine_out}" +fi +assert_eq "${called}" "" "non-Debian base should not call an installer" + +set +e +invalid_out="$(EXTENSIONS='zip;rm' main 2>&1)" +invalid_rc=$? +set -e +if [[ "${invalid_rc}" -eq 0 ]]; then + fail "invalid extension names should fail" +fi +if [[ "${invalid_out}" != *"invalid extension name"* ]]; then + fail "invalid extension should be rejected: ${invalid_out}" +fi + +echo "ok"