From b136f28f2120e0f0ef24ab23218c7f7ad8243a16 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 13:28:25 +0000 Subject: [PATCH 1/3] Split image tests into one file per feature area Move the checks in scripts/test-image.sh into scripts/tests/image/ with shared helpers in lib.sh and PHP fixtures as real files. Every test runs even after one fails, and TEST_ONLY runs a single area. No checks were added or changed. --- .github/workflows/service_lint.yml | 1 + AGENTS.md | 13 +- .../docs/1.getting-started/99.contributing.md | 30 +- scripts/test-image.sh | 358 ++++-------------- scripts/tests/image/.shellcheckrc | 3 + .../image/fixtures/public/finish-request.php | 4 + .../fixtures/public/frankenphp-worker.php | 6 + scripts/tests/image/fixtures/public/index.php | 1 + .../fixtures/public/storage/uploaded.php | 1 + scripts/tests/image/frankenphp.sh | 12 + scripts/tests/image/healthcheck.sh | 7 + scripts/tests/image/lib.sh | 128 +++++++ scripts/tests/image/octane.sh | 42 ++ scripts/tests/image/php.sh | 49 +++ scripts/tests/image/security.sh | 17 + scripts/tests/image/ssl.sh | 36 ++ scripts/tests/image/web-server.sh | 15 + 17 files changed, 433 insertions(+), 290 deletions(-) create mode 100644 scripts/tests/image/.shellcheckrc create mode 100644 scripts/tests/image/fixtures/public/finish-request.php create mode 100644 scripts/tests/image/fixtures/public/frankenphp-worker.php create mode 100644 scripts/tests/image/fixtures/public/index.php create mode 100644 scripts/tests/image/fixtures/public/storage/uploaded.php create mode 100644 scripts/tests/image/frankenphp.sh create mode 100644 scripts/tests/image/healthcheck.sh create mode 100644 scripts/tests/image/lib.sh create mode 100644 scripts/tests/image/octane.sh create mode 100644 scripts/tests/image/php.sh create mode 100644 scripts/tests/image/security.sh create mode 100644 scripts/tests/image/ssl.sh create mode 100644 scripts/tests/image/web-server.sh diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml index 380c0300..1e9b8012 100644 --- a/.github/workflows/service_lint.yml +++ b/.github/workflows/service_lint.yml @@ -43,6 +43,7 @@ jobs: --color=always --severity=warning \ scripts/*.sh \ scripts/tests/*.sh \ + scripts/tests/image/*.sh \ src/common/usr/local/bin/* \ src/s6/usr/local/bin/* \ .oss-scanner/run-variation \ diff --git a/AGENTS.md b/AGENTS.md index 10667d97..a420ecd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,18 +115,19 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - a new way for untrusted input to reach the image (a web server location, a healthcheck, a workflow trigger), a change to `SECURITY.md` scope, or a new intentional default the scanner should not report: `.oss-scanner/threat_model.md` After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. -- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name. +- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, served PHP files in `fixtures/public/`. Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. - `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. ## Verification -There is no automated test suite for image logic. To verify changes: +To verify image changes: 1. Build the affected variation locally with `scripts/dev.sh` -2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` -3. For entrypoint script changes, test on both Debian and Alpine builds -4. Run `shellcheck` on any modified shell scripts when available +2. Run `bash scripts/test-image.sh ` against it, and add a test in `scripts/tests/image/` for new behavior +3. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` +4. For entrypoint script changes, test on both Debian and Alpine builds +5. Run `shellcheck` on any modified shell scripts when available ## Key Design Decisions diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index c0f169dd..7dd73f3a 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -98,7 +98,7 @@ Every pull request starts with a `lint` group that runs before any image is buil docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \ - scripts/*.sh scripts/tests/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') + scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile @@ -109,6 +109,34 @@ The script tests live in one file, `scripts/tests/run.sh`, as plain assertions o The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. +### Image tests +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `security.sh`, `web-server.sh`, `ssl.sh`, `octane.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: + +```bash [Terminal] +bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx + +# Run one feature area +TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx +``` + +Every test runs even after one fails, and the summary lists each failure with its output. + +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature, and `setup` starts one container that every test in the file shares: + +```bash [scripts/tests/image/web-server.sh] +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} +``` + +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. PHP files the web server should serve go in `scripts/tests/image/fixtures/public/`. + ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. diff --git a/scripts/test-image.sh b/scripts/test-image.sh index 130f071f..3043cad8 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,89 +1,45 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034 # the image facts are read by the files in scripts/tests/image/ set -euo pipefail # Usage: test-image.sh [expected-php-version] # -# Runs a published or locally built image and checks the things a user would notice -# first: it starts, it runs as an unprivileged user, nothing is world-writable, PHP reports the expected version, -# the default extensions load, and images that ship a web server serve a PHP file -# through it. Works against any image reference. -# Commands go through the image's own entrypoint so every entrypoint.d script runs. -# The entrypoint prints a welcome banner first, so a command's own output is the last line. +# Runs a published or locally built image through the tests in scripts/tests/image/, +# one file per feature area. A file skips itself when the image lacks its feature, +# so the same tests run against every variation. Every test runs even after one +# fails, and the script exits non-zero if any did. +# +# Set TEST_ONLY to a file name without .sh (for example web-server) to run one area. image="${1:?Usage: test-image.sh [expected-php-version]}" expected_php="${2:-}" -health_timeout_seconds=90 -http_timeout_seconds=30 - -# Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). -expected_extensions="opcache pcntl pdo_mysql pdo_pgsql redis zip" - -pass() { echo "✅ $1"; } -fail() { echo "❌ $1" >&2; exit 1; } -image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } - -echo "🔎 Testing $image" - -php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1) -# A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 -if [[ "$expected_php" == *-rc ]] && [[ "$php_version" == "${expected_php%-rc}".*RC* ]]; then - expected_php="$php_version" -fi -if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then - fail "PHP reports $php_version, expected $expected_php" -fi -pass "PHP $php_version" +tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/tests/image" && pwd)" -uid=$(docker run --rm "$image" id -u | tail -n1) -[ "$uid" != "0" ] || fail "Container runs as root by default" -pass "Runs as unprivileged user (uid $uid)" +# shellcheck source=tests/image/lib.sh +source "$tests_dir/lib.sh" -# A COPY without --chmod gives existing destination directories the build context's modes. -# Sticky directories like /tmp are expected. -world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ - 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') -[ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" -pass "No world-writable files or directories" - -loaded_extensions=$(docker run --rm "$image" php -r 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));' | tail -n1) -missing_extensions="" -for extension in $expected_extensions; do - case " $loaded_extensions " in - *" $extension "*) ;; - *"zend $extension "*) ;; - *) missing_extensions="$missing_extensions $extension" ;; - esac -done -[ -z "$missing_extensions" ] || fail "PHP extensions not loaded:$missing_extensions" -pass "Extensions loaded: $expected_extensions" - -# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few -# of the different value types (size, boolean, list) and confirm PHP sees them. -# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. -ini_values=$(docker run --rm \ - --env PHP_MEMORY_LIMIT=512M \ - --env PHP_REALPATH_CACHE_SIZE=8M \ - --env PHP_SESSION_COOKIE_HTTPONLY=0 \ - --env PHP_DISABLE_FUNCTIONS=shell_exec \ - --env PHP_OPCACHE_ENABLE=1 \ - --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60 \ - "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' | tail -n1) -[ "$ini_values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values" -pass "Environment variables apply to php.ini" +test_label="serversideup-php-test=$$" +work_dir=$(mktemp -d) +cleanup() { + local containers + containers=$(docker ps --all --quiet --filter "label=$test_label") + # shellcheck disable=SC2086 # one container ID per word + [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + rm -rf "$work_dir" +} +trap cleanup EXIT -# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. -cli_opcache=$(docker run --rm --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0 \ - "$image" php -r 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' | tail -n1) -[ "$cli_opcache" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $cli_opcache" -pass "PHP_OPCACHE_ENABLE_CLI disables the CLI cache" +# The container runs unprivileged, so the mounted document root must be world readable. +web_dir="$work_dir/public" +cp -R "$tests_dir/fixtures/public" "$web_dir" +chmod -R u=rwX,go=rX "$web_dir" +php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") -if [ -z "$has_healthcheck" ]; then - pass "No HEALTHCHECK defined, skipping startup check" - exit 0 -fi +is_frankenphp=$(image_env CADDY_HTTP_PORT) -# Web images expose their HTTP port and document root as environment variables. +# Web images expose their HTTP port and document root as environment variables, and +# serve TLS on their HTTPS port once SSL_MODE is on. http_port="" web_root="" for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT CADDY_HTTP_PORT:CADDY_SERVER_ROOT; do @@ -95,229 +51,65 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT break fi done - -# The same images serve TLS on their HTTPS port once SSL_MODE is on, which is the only -# way to reach them over HTTP/2. https_port="" for variable in NGINX_HTTPS_PORT APACHE_HTTPS_PORT CADDY_HTTPS_PORT; do https_port=$(image_env "$variable") [ -z "$https_port" ] || break done -# Web images run with OPcache enabled so the health check and the served page -# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. -run_args=(--detach --env PHP_OPCACHE_ENABLE=1) -if [ -n "$http_port" ]; then - # The container runs unprivileged, so the mounted document root must be world readable. - web_dir=$(mktemp -d) - chmod 755 "$web_dir" - echo ' "$web_dir/index.php" - chmod 644 "$web_dir/index.php" - mkdir -p "$web_dir/storage" - chmod 755 "$web_dir/storage" - echo ' "$web_dir/storage/uploaded.php" - chmod 644 "$web_dir/storage/uploaded.php" - # Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running - # terminate callbacks after it, so the web server has to survive a request that finishes - # before PHP does. - cat > "$web_dir/finish-request.php" <<'PHP' -/dev/null 2>&1 || true - done - [ -z "${web_dir:-}" ] || rm -rf "$web_dir" -} -trap cleanup EXIT - -dump_container_state() { - echo "--- container logs ---" >&2 - docker logs "$1" >&2 2>&1 || true - echo "--- last health check ---" >&2 - docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true -} - -start_container() { - container=$(docker run "${run_args[@]}" "$@" "$image") - containers+=("$container") - - status=starting - for _ in $(seq 1 "$health_timeout_seconds"); do - status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") - case "$status" in - healthy) break ;; - unhealthy|gone) break ;; - esac - sleep 1 - done +failures_file="$work_dir/failures" +touch "$failures_file" - if [ "$status" != "healthy" ]; then - dump_container_state "$container" - fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" +report() { + local name="$1" status="$2" output="$3" + if [ "$status" -eq 0 ]; then + echo "✅ $name" + return fi - [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') - [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') + echo "❌ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" } -# Retries until the response body matches, since the web server may still be warming up. -expect_body() { - path="$1" - expected="$2" - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) - [ "$body" = "$expected" ] && return 0 - sleep 1 - done - dump_container_state "$container" - fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" -} - -# Uploaded PHP files under /storage must never run, including through PATH_INFO -# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. -expect_storage_blocked() { - for path in /storage/uploaded.php /storage/uploaded.php/anything; do - response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) - [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" - done -} - -# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. -# Retries because the log line lands a moment after the response. -expect_authorization_redacted() { - curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true - for _ in $(seq 1 "$http_timeout_seconds"); do - logs=$(docker logs "$container" 2>&1) - case "$logs" in - *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; - *"authorization=REDACTED"*) return 0 ;; - esac - sleep 1 - done - dump_container_state "$container" - fail "Access log does not redact the authorization query parameter" -} - -# PHP carrying on after fastcgi_finish_request() has to leave the server able to take the -# next request. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only over -# HTTP/2, so both protocol versions are worth checking. -expect_work_after_finish_request() { - for protocol in --http1.1 --http2; do - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) - [ "$body" = "finish-request-ok:${php_version}" ] && break - sleep 1 - done - if [ "$body" != "finish-request-ok:${php_version}" ]; then - dump_container_state "$container" - fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" - fi - - # The crash lands after the response, so give the process a moment to fall over. - sleep 2 - status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) - if [ "$status" != "running" ]; then - dump_container_state "$container" - fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" - fi +# Runs in its own subshell so one file's containers and variables never reach the next. +# setup() runs in the file's shell so the tests can use the container it starts; an EXIT +# trap reports it when it fails. Each test runs in a subshell with errexit on, so a failed +# command ends that test only. +run_test_file() { + local file="$1" output="$work_dir/output" test_function status + # shellcheck source=/dev/null + source "$file" + if declare -F setup >/dev/null; then + exec 3>&1 + trap 'report setup 1 "$output" >&3' EXIT + setup >"$output" 2>&1 + trap - EXIT + fi + for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do + set +e + ( set -e; "$test_function" ) >"$output" 2>&1 + status=$? + set -e + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" "$output" done } -# A detached container has no terminal, so Caddy's default format is json and its default -# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. -expect_json_logs_on_stderr() { - stdout_logs=$(docker logs "$container" 2>/dev/null) - stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) - case "$stderr_logs" in - *'"msg":"handled request"'*) ;; - *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; - esac - case "$stdout_logs" in - *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; - esac -} - -start_container -pass "Container became healthy" - -[ -n "$http_port" ] || exit 0 - -expect_body / "serversideup-php-ok:${php_version}" -pass "Web server serves PHP on port ${http_port}" - -expect_storage_blocked -pass "Web server blocks PHP execution under /storage" +echo "🔎 Testing $image (PHP $php_version)" +for file in "$tests_dir"/*.sh; do + area=$(basename "$file" .sh) + [ "$area" != lib ] || continue + [ -z "${TEST_ONLY:-}" ] || [ "$area" = "$TEST_ONLY" ] || continue + echo + echo "$area" + set +e + ( set -e; run_test_file "$file" ) + set -e +done -# HTTP/2 needs TLS, so this runs against its own container with SSL_MODE=full, which -# generates a self-signed certificate and stops serving the plain HTTP port. The checks -# after it still expect the container the rest of the suite has been using. -if [ -n "$https_port" ]; then - default_container="$container" - default_host_port="$host_port" - start_container --env SSL_MODE=full - expect_work_after_finish_request - pass "Web server survives a request that finishes before PHP does, over HTTP/1.1 and HTTP/2" - container="$default_container" - host_port="$default_host_port" +echo +if [ -s "$failures_file" ]; then + echo "$(wc -l < "$failures_file" | tr -d ' ') test(s) failed:" >&2 + sed 's/^/ /' "$failures_file" >&2 + exit 1 fi - -# The rest applies to FrankenPHP only: Caddy's log defaults and Laravel Octane. -[ -n "$(image_env CADDY_HTTP_PORT)" ] || exit 0 - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Web server writes JSON logs to stderr and redacts the authorization query parameter" - -# Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from -# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch -# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. -cat > "$web_dir/frankenphp-worker.php" <<'PHP' -}" -pass "Octane mode exposes the Caddy admin API on Octane's admin port" - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Octane mode writes JSON logs to stderr and redacts the authorization query parameter" +echo "All tests passed" diff --git a/scripts/tests/image/.shellcheckrc b/scripts/tests/image/.shellcheckrc new file mode 100644 index 00000000..f8238048 --- /dev/null +++ b/scripts/tests/image/.shellcheckrc @@ -0,0 +1,3 @@ +# The test files read and set variables that scripts/test-image.sh and lib.sh share +# with them (image, php_version, container, ...), which ShellCheck can't see across files. +disable=SC2034,SC2154 diff --git a/scripts/tests/image/fixtures/public/finish-request.php b/scripts/tests/image/fixtures/public/finish-request.php new file mode 100644 index 00000000..ad27d392 --- /dev/null +++ b/scripts/tests/image/fixtures/public/finish-request.php @@ -0,0 +1,4 @@ +&2; exit 1; } +image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } + +# Usage: run_php [docker run options] +run_php() { + local code="$1" + shift + docker run --rm "$@" "$image" php -r "$code" | tail -n1 +} + +# Skips the rest of the test file when the image lacks one of the named features. +requires() { + local feature missing + for feature in "$@"; do + case "$feature" in + healthcheck) [ -n "$has_healthcheck" ] || missing="no HEALTHCHECK" ;; + web_server) [ -n "$http_port" ] || missing="no web server" ;; + https) [ -n "$https_port" ] || missing="no HTTPS port" ;; + frankenphp) [ -n "$is_frankenphp" ] || missing="not FrankenPHP" ;; + *) fail "Unknown requirement: $feature" ;; + esac + if [ -n "${missing:-}" ]; then + echo "⏭️ skipped: $missing" + exit 0 + fi + done +} + +dump_container_state() { + echo "--- container logs ---" >&2 + docker logs "$1" >&2 2>&1 || true + echo "--- last health check ---" >&2 + docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true +} + +# Starts the image in the background and waits for its HEALTHCHECK. Web images get the +# fixtures mounted as their document root and their ports published on random host ports. +# Web images run with OPcache enabled so the health check and the served page +# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. +# Sets container, host_port, and https_host_port. +start_container() { + local args=(--detach --label "$test_label" --env PHP_OPCACHE_ENABLE=1) status=starting + if [ -n "$http_port" ]; then + args+=(--publish "127.0.0.1::${http_port}" --volume "$web_dir:$web_root:ro") + [ -z "$https_port" ] || args+=(--publish "127.0.0.1::${https_port}") + fi + container=$(docker run "${args[@]}" "$@" "$image") + + for _ in $(seq 1 "$health_timeout_seconds"); do + status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") + case "$status" in + healthy|unhealthy|gone) break ;; + esac + sleep 1 + done + + if [ "$status" != "healthy" ]; then + dump_container_state "$container" + fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" + fi + [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') + [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') +} + +# Retries until the response body matches, since the web server may still be warming up. +expect_body() { + local path="$1" expected="$2" body="" + for _ in $(seq 1 "$http_timeout_seconds"); do + body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) + [ "$body" = "$expected" ] && return 0 + sleep 1 + done + dump_container_state "$container" + fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" +} + +# Uploaded PHP files under /storage must never run, including through PATH_INFO +# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. +expect_storage_blocked() { + local path response + for path in /storage/uploaded.php /storage/uploaded.php/anything; do + response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) + [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" + done +} + +# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. +# Retries because the log line lands a moment after the response. +expect_authorization_redacted() { + local logs + curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true + for _ in $(seq 1 "$http_timeout_seconds"); do + logs=$(docker logs "$container" 2>&1) + case "$logs" in + *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; + *"authorization=REDACTED"*) return 0 ;; + esac + sleep 1 + done + dump_container_state "$container" + fail "Access log does not redact the authorization query parameter" +} + +# A detached container has no terminal, so Caddy's default format is json and its default +# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. +# Run it after a request has been logged. +expect_json_logs_on_stderr() { + local stdout_logs stderr_logs + stdout_logs=$(docker logs "$container" 2>/dev/null) + stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) + case "$stderr_logs" in + *'"msg":"handled request"'*) ;; + *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; + esac + case "$stdout_logs" in + *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; + esac +} diff --git a/scripts/tests/image/octane.sh b/scripts/tests/image/octane.sh new file mode 100644 index 00000000..b0907989 --- /dev/null +++ b/scripts/tests/image/octane.sh @@ -0,0 +1,42 @@ +# shellcheck shell=bash +# Laravel Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from +# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch +# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. +requires healthcheck web_server frankenphp + +# A custom CADDY_SERVER_ROOT must move the worker script along with the document root. +octane_root=/var/www/octane/public + +setup() { + start_container \ + --env LARAVEL_OCTANE=1 \ + --env "CADDY_SERVER_ROOT=$octane_root" \ + --volume "$web_dir:$octane_root:ro" \ + --env CADDY_SERVER_ADMIN_HOST=localhost \ + --env CADDY_SERVER_ADMIN_PORT=2099 \ + --env "CADDY_SERVER_WORKER_DIRECTIVE=num 2" \ + --env "CADDY_SERVER_WATCH_DIRECTIVES=watch $octane_root" \ + --env "CADDY_GLOBAL_OPTIONS=auto_https disable_redirects" \ + --env CADDY_SERVER_EXTRA_DIRECTIVES= +} + +test_routes_requests_to_frankenphp_worker() { + expect_body / "octane-worker-ok:${php_version}" + expect_body /some/route "octane-worker-ok:${php_version}" +} + +test_blocks_php_under_storage() { + expect_storage_blocked +} + +# octane:status, octane:reload, and octane:stop use the Caddy admin API on Octane's port. +test_exposes_the_caddy_admin_api_on_octanes_port() { + local response + response=$(docker exec "$container" curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' http://localhost:2099/config/apps/frankenphp || true) + [ "$response" = "200" ] || fail "Caddy admin API is not reachable on Octane's admin port, got ${response:-}" +} + +test_writes_json_logs_to_stderr_and_redacts_authorization() { + expect_authorization_redacted + expect_json_logs_on_stderr +} diff --git a/scripts/tests/image/php.sh b/scripts/tests/image/php.sh new file mode 100644 index 00000000..50cf5a68 --- /dev/null +++ b/scripts/tests/image/php.sh @@ -0,0 +1,49 @@ +# shellcheck shell=bash +# PHP itself: the version, the default extensions, and php.ini settings from environment variables. + +test_reports_the_expected_php_version() { + local expected="$expected_php" + [ -n "$expected" ] || return 0 + # A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 + if [[ "$expected" == *-rc ]] && [[ "$php_version" == "${expected%-rc}".*RC* ]]; then + expected="$php_version" + fi + [ "$php_version" = "$expected" ] || fail "PHP reports $php_version, expected $expected" +} + +test_loads_the_default_extensions() { + # Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). + local expected="opcache pcntl pdo_mysql pdo_pgsql redis zip" loaded missing="" extension + loaded=$(run_php 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));') + for extension in $expected; do + case " $loaded " in + *" $extension "*) ;; + *"zend $extension "*) ;; + *) missing="$missing $extension" ;; + esac + done + [ -z "$missing" ] || fail "PHP extensions not loaded:$missing" +} + +# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few +# of the different value types (size, boolean, list) and confirm PHP sees them. +# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. +test_applies_environment_variables_to_php_ini() { + local values + values=$(run_php 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' \ + --env PHP_MEMORY_LIMIT=512M \ + --env PHP_REALPATH_CACHE_SIZE=8M \ + --env PHP_SESSION_COOKIE_HTTPONLY=0 \ + --env PHP_DISABLE_FUNCTIONS=shell_exec \ + --env PHP_OPCACHE_ENABLE=1 \ + --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60) + [ "$values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $values" +} + +# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. +test_disables_opcache_for_the_cli() { + local status + status=$(run_php 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' \ + --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0) + [ "$status" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $status" +} diff --git a/scripts/tests/image/security.sh b/scripts/tests/image/security.sh new file mode 100644 index 00000000..c0d3520b --- /dev/null +++ b/scripts/tests/image/security.sh @@ -0,0 +1,17 @@ +# shellcheck shell=bash +# Defaults that keep a compromised app from taking over the container. + +test_runs_as_an_unprivileged_user() { + local uid + uid=$(docker run --rm "$image" id -u | tail -n1) + [ "$uid" != "0" ] || fail "Container runs as root by default" +} + +# A COPY without --chmod gives existing destination directories the build context's modes. +# Sticky directories like /tmp are expected. +test_has_no_world_writable_files_or_directories() { + local world_writable + world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ + 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') + [ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" +} diff --git a/scripts/tests/image/ssl.sh b/scripts/tests/image/ssl.sh new file mode 100644 index 00000000..82733961 --- /dev/null +++ b/scripts/tests/image/ssl.sh @@ -0,0 +1,36 @@ +# shellcheck shell=bash +# SSL_MODE=full generates a self-signed certificate and stops serving the plain HTTP port. +# It is also the only way to reach the web server over HTTP/2. +requires healthcheck web_server https + +setup() { + start_container --env SSL_MODE=full +} + +# Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running +# terminate callbacks after it, so the web server has to survive a request that finishes +# before PHP does. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only +# over HTTP/2, so both protocol versions are worth checking. +test_survives_requests_that_finish_before_php_over_http1_and_http2() { + local protocol body status + for protocol in --http1.1 --http2; do + body="" + for _ in $(seq 1 "$http_timeout_seconds"); do + body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) + [ "$body" = "finish-request-ok:${php_version}" ] && break + sleep 1 + done + if [ "$body" != "finish-request-ok:${php_version}" ]; then + dump_container_state "$container" + fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" + fi + + # The crash lands after the response, so give the process a moment to fall over. + sleep 2 + status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) + if [ "$status" != "running" ]; then + dump_container_state "$container" + fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" + fi + done +} diff --git a/scripts/tests/image/web-server.sh b/scripts/tests/image/web-server.sh new file mode 100644 index 00000000..84e65b17 --- /dev/null +++ b/scripts/tests/image/web-server.sh @@ -0,0 +1,15 @@ +# shellcheck shell=bash +# NGINX, Apache, or Caddy serving the mounted fixtures in fixtures/public. +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} + +test_blocks_php_under_storage() { + expect_storage_blocked +} From f75b1e6785f34615a91462832b42240cb958f61b Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 13:40:39 +0000 Subject: [PATCH 2/3] Test Laravel automations, startup scripts, set-id, and SSL modes Add laravel-automations.sh (a stand-in artisan records each command), entrypoint.sh, permissions.sh, and SSL mixed mode and HTTPS redirect tests. requires now also skips a single test, and build_image builds a test image from the one under test. --- AGENTS.md | 2 +- .../docs/1.getting-started/99.contributing.md | 8 +- scripts/test-image.sh | 63 +++++++++------ scripts/tests/image/entrypoint.sh | 38 +++++++++ .../image/fixtures/entrypoint.d/10-second.sh | 3 + .../image/fixtures/entrypoint.d/100-third.sh | 1 + .../image/fixtures/entrypoint.d/2-first.sh | 1 + .../image/fixtures/entrypoint.d/20-fails.sh | 2 + scripts/tests/image/fixtures/laravel/artisan | 11 +++ .../fixtures/laravel/vendor/autoload.php | 1 + scripts/tests/image/laravel-automations.sh | 80 +++++++++++++++++++ scripts/tests/image/lib.sh | 26 ++++-- scripts/tests/image/permissions.sh | 27 +++++++ scripts/tests/image/ssl.sh | 17 +++- 14 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 scripts/tests/image/entrypoint.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/10-second.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/100-third.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/2-first.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/20-fails.sh create mode 100644 scripts/tests/image/fixtures/laravel/artisan create mode 100644 scripts/tests/image/fixtures/laravel/vendor/autoload.php create mode 100644 scripts/tests/image/laravel-automations.sh create mode 100644 scripts/tests/image/permissions.sh diff --git a/AGENTS.md b/AGENTS.md index a420ecd6..b9ce5adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config. After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. - Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, served PHP files in `fixtures/public/`. Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, mounted files in `fixtures/` (`laravel/artisan` records commands instead of running Laravel). Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file or test declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. - `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index 7dd73f3a..23178c98 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -110,7 +110,7 @@ The script tests live in one file, `scripts/tests/run.sh`, as plain assertions o The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. ### Image tests -`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `security.sh`, `web-server.sh`, `ssl.sh`, `octane.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `entrypoint.sh`, `laravel-automations.sh`, `web-server.sh`, `ssl.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: ```bash [Terminal] bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx @@ -121,7 +121,7 @@ TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx Every test runs even after one fails, and the summary lists each failure with its output. -To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature, and `setup` starts one container that every test in the file shares: +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature (or just the test, when you call it inside one), and `setup` starts one container that every test in the file shares: ```bash [scripts/tests/image/web-server.sh] requires healthcheck web_server @@ -135,7 +135,7 @@ test_serves_php() { } ``` -A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. PHP files the web server should serve go in `scripts/tests/image/fixtures/public/`. +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. Files the tests mount go in `scripts/tests/image/fixtures/`: `public/` is the web server's document root, `laravel/` is a stand-in Laravel app whose `artisan` records each command it receives, and `entrypoint.d/` holds sample startup scripts. ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. @@ -143,7 +143,7 @@ Every pull request that touches `src/`, `scripts/`, or the workflows builds the - **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. Fork builds can't save the compiled PHP base images, so they start from the official PHP images on Docker Hub instead. In the hours after a new PHP release, before Docker Hub has it, fork builds fail. Changes to how PHP is compiled are only tested when a maintainer runs the build for your PR. - **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands. - **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge. -- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. +- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, serves PHP through its web server in every SSL mode, and runs startup scripts and Laravel automations the way the docs describe. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. - **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors. - **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number. diff --git a/scripts/test-image.sh b/scripts/test-image.sh index 3043cad8..0252034a 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 # the image facts are read by the files in scripts/tests/image/ +# shellcheck disable=SC2034,SC2154 # variables are shared with scripts/tests/image/lib.sh and the test files set -euo pipefail # Usage: test-image.sh [expected-php-version] @@ -21,18 +21,22 @@ source "$tests_dir/lib.sh" test_label="serversideup-php-test=$$" work_dir=$(mktemp -d) cleanup() { - local containers + local containers images containers=$(docker ps --all --quiet --filter "label=$test_label") - # shellcheck disable=SC2086 # one container ID per word + images=$(docker images --quiet --filter "label=$test_label" | sort -u) + # shellcheck disable=SC2086 # one ID per word [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + # shellcheck disable=SC2086 + [ -z "$images" ] || docker rmi --force $images >/dev/null 2>&1 || true rm -rf "$work_dir" } trap cleanup EXIT -# The container runs unprivileged, so the mounted document root must be world readable. -web_dir="$work_dir/public" -cp -R "$tests_dir/fixtures/public" "$web_dir" -chmod -R u=rwX,go=rX "$web_dir" +# The container runs unprivileged, so mounted fixtures must be world readable. +fixtures_dir="$work_dir/fixtures" +cp -R "$tests_dir/fixtures" "$fixtures_dir" +chmod -R u=rwX,go=rX "$fixtures_dir" +web_dir="$fixtures_dir/public" php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") @@ -60,37 +64,50 @@ done failures_file="$work_dir/failures" touch "$failures_file" +output="$work_dir/output" + report() { - local name="$1" status="$2" output="$3" - if [ "$status" -eq 0 ]; then - echo "✅ $name" - return + local name="$1" status="$2" + case "$status" in + 0) echo "✅ $name" ;; + "$skip_status") echo "⏭️ $name, $(tail -n1 "$output")" ;; + *) + echo "❌ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" + ;; + esac +} + +# A file stops early when requires skips it or setup fails. +report_file_stopped() { + if [ "$1" -eq "$skip_status" ]; then + echo "⏭️ $(tail -n1 "$output")" + else + report setup "$1" fi - echo "❌ $name" - sed 's/^/ /' "$output" - echo "$area: $name" >> "$failures_file" } # Runs in its own subshell so one file's containers and variables never reach the next. -# setup() runs in the file's shell so the tests can use the container it starts; an EXIT -# trap reports it when it fails. Each test runs in a subshell with errexit on, so a failed -# command ends that test only. +# The file and its setup() run in that shell so the tests can use the container setup +# starts; an EXIT trap reports them when they stop it. Each test runs in a subshell with +# errexit on, so a failed command ends that test only. run_test_file() { - local file="$1" output="$work_dir/output" test_function status + local file="$1" test_function status + exec 3>&1 + trap 'report_file_stopped $? >&3' EXIT # shellcheck source=/dev/null - source "$file" + source "$file" >"$output" 2>&1 if declare -F setup >/dev/null; then - exec 3>&1 - trap 'report setup 1 "$output" >&3' EXIT setup >"$output" 2>&1 - trap - EXIT fi + trap - EXIT for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do set +e ( set -e; "$test_function" ) >"$output" 2>&1 status=$? set -e - report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" "$output" + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" done } diff --git a/scripts/tests/image/entrypoint.sh b/scripts/tests/image/entrypoint.sh new file mode 100644 index 00000000..c57c80cd --- /dev/null +++ b/scripts/tests/image/entrypoint.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash +# docker-php-serversideup-entrypoint: the scripts in /etc/entrypoint.d, then the command. + +# Usage: run_entrypoint