Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions features/install-php-extensions/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions features/install-php-extensions/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
157 changes: 151 additions & 6 deletions features/install-php-extensions/install.sh
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions tests/install-php-extensions.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading