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..7ed41c0f 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`, `fpm.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `lifecycle.sh`, `web-server.sh`, `ssl.sh`, `trusted-proxy.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`, `fpm`), 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..ce6fe791 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,13 +109,41 @@ 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`, `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 + +# 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 (or just the test, when you call it inside one; `skip "reason"` skips one for any other reason), 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`. 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. - **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, stops cleanly, serves PHP through its web server in every SSL mode and trusted proxy setting, 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 130f071f..db969b94 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,89 +1,50 @@ #!/usr/bin/env bash +# 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] # -# 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 images + containers=$(docker ps --all --quiet --filter "label=$test_label") + 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 -# 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 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") -if [ -z "$has_healthcheck" ]; then - pass "No HEALTHCHECK defined, skipping startup check" - exit 0 -fi +is_frankenphp=$(image_env CADDY_HTTP_PORT) +is_fpm=$(image_env PHP_FPM_POOL_NAME) -# 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 +56,78 @@ 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 +failures_file="$work_dir/failures" +touch "$failures_file" + +output="$work_dir/output" + +report() { + 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 } -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 - - if [ "$status" != "healthy" ]; then - dump_container_state "$container" - fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" +# 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 - [ -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() { - 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. +# 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" test_function status + exec 3>&1 + trap 'report_file_stopped $? >&3' EXIT + # shellcheck source=/dev/null + source "$file" >"$output" 2>&1 + if declare -F setup >/dev/null; then + setup >"$output" 2>&1 + 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" 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/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