Files
cloud-apache-container/Dockerfile.lsphp
T
shadowdaoandClaude Opus 5.5 71ca114529 feat(images): upgrade lsphp from the LiteSpeed repo at build time; label PHP version
cac-litespeed and cac-lsphp start FROM litespeedtech/openlitespeed:1.8.4-lsphpNN,
which ships lsphp85 8.5.0 / lsphp84 8.4.15 (built 2025-12). lsphp only moved
forward as a side effect of installing lsphpNN-ldap. lsphp-apt-upgrade.sh now
sweeps every installed lsphpNN* package (and OS packages) to the repo's current
release on every build and fails the build if the repo is unreachable or the
result doesn't match what CI resolved from the repo index. CI passes a per-run
CACHE_BUST so the upgrade layer is never a cache hit, and images carry
org.whp.php-version / org.whp.lsphp-package-version labels.

Tested on .148 (2026-10-21): cac-lsphp php85/php84 and cac-litespeed php85 built
from this branch report the same PHP (8.5.11 / 8.4.26), identical module lists
and OLS 1.8.4 as the current registry images; with the hardening flags each
serves/runs PHP as the customer uid, restarts, and stops cleanly (exit 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:07:48 -07:00

259 lines
14 KiB
Docker

## cac-lsphp — per-site DETACHED lsphp (LSAPI) backend for the shared-ols tier.
##
## The LiteSpeed analogue of cac-fpm: a slim, single-tenant PHP backend that
## runs `lsphp -b 0.0.0.0:9000` (detached LSAPI mode) and NOTHING ELSE — no
## webserver. The shared OpenLiteSpeed container (shared-ols) sits in front and
## reaches this over the docker network via an extProcessor of type lsapi,
## address <this-container>:9000 — structurally identical to how shared-httpd
## reaches a cac-fpm container's php-fpm on :9000.
##
## Built on the SAME LiteSpeed prebuilt base as cac-litespeed so the lsphp
## binary + extension set are byte-for-byte the runtime customers already get
## on the litespeed tier (memcached, redis, imagick, mbstring, mysqlnd, intl,
## gd, soap, bcmath, gmp, sodium, opcache, ... + lsphpNN-ldap added below).
## We do NOT strip the bundled OpenLiteSpeed binaries: the "no webserver"
## guarantee comes from the ENTRYPOINT (it only ever execs lsphp), and deleting
## OLS files from the upstream image risks breaking lsphp's shared libs for no
## real benefit. Only :9000 is EXPOSEd, and OLS is never started.
##
## See the design spec + PoC: whp docs/superpowers/plans/2026-06-09-ols-lsphp-tier.md
## and the LSAPI path-parity finding (feedback_ols_lsapi_no_script_filename_remap).
ARG OLS_VERSION=1.8.4
ARG PHPVER=83
## ---- stage 1: build the cac_path_parity extension --------------------------
## $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] parity with cac-fpm, enforced
## from RINIT so a customer's .user.ini cannot displace it — see
## ext/cac-path-parity/cac_path_parity.c for why this is an extension and not an
## auto_prepend_file.
##
## WHICH lsphp THE .so IS BUILT AGAINST — read this before touching the apt lines.
## `lsphp${PHPVER}-dev` is NOT available at the version the base image ships:
## the LiteSpeed apt repo carries only the CURRENT release, and every prebuilt
## OLS base image is behind it (measured 2026-08-05 on OLS 1.8.4:
## lsphp81 8.1.33 base / 8.1.34 repo, lsphp83 8.3.28 / 8.3.32,
## lsphp85 8.5.0 / 8.5.8).
## Pinning -dev to the base version fails on all three with
## `E: Version '<base>' for 'lsphp<NN>-dev' was not found`.
##
## So installing -dev necessarily UPGRADES lsphp in this stage. The parity we can
## have — and the one this file now guarantees — is the other direction: the
## shipped runtime is pinned to whatever version this stage compiled against.
## That version is recorded here and consumed by stage 2, so the two apt layers
## are cache-locked to each other. Without this, `COPY ./ext` invalidating only
## THIS stage while stage 2's apt layer stayed cached produced a real, repeatable
## skew (reviewer measured a .so built on 8.3.32 shipped next to an 8.3.30
## runtime, with lsphp83-common at 8.3.31 — the vendor family is not always
## uniformly versioned either).
##
## Benign in practice (PHP holds ABI stable across a patch series) but it is the
## riskier direction — headers NEWER than the runtime — and the runtime assertion
## in stage 2 catches only load failure, never silent struct-layout drift.
##
## Separate stage on purpose: the compiler + headers (~400MB) stay out of the
## shipped image, which gains only the ~40KB .so. Costs ~1-2 min of CI per PHP
## version; both stages share the same base layer, so no extra pull.
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} AS ext-build
ARG PHPVER=83
## Toolchain layer, deliberately BEFORE the source COPY so editing the extension
## does not re-resolve the PHP version (which is what caused the skew above).
## Records the exact lsphp version the headers belong to; stage 2 pins to it.
##
## lsphp-apt-upgrade.sh (see its header) refuses to run on stale/unreachable
## repo lists and sweeps every lsphp${PHPVER}* package to the repo's current
## release. CACHE_BUST is per-CI-run so this layer — and therefore the recorded
## version stage 2 pins to — is never a cache hit; it is re-declared in stage 2.
## --no-os-upgrade: this stage is thrown away, OS updates would only cost time.
## EXPECT_* (CI's repo-index lookup) are asserted here too, so a mismatch fails
## before the compile rather than after it.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
/usr/local/sbin/lsphp-apt-upgrade.sh --no-os-upgrade "${PHPVER}" \
build-essential autoconf pkg-config \
lsphp${PHPVER}-dev; \
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
DEVV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-dev); \
if [ "$RTV" != "$DEVV" ]; then \
echo "FATAL: lsphp${PHPVER}=$RTV but lsphp${PHPVER}-dev=$DEVV — the headers" >&2; \
echo " do not belong to the PHP in this stage. Refusing to build." >&2; \
exit 1; \
fi; \
mkdir -p /build-out; \
printf '%s' "$RTV" > /build-out/lsphp.version; \
echo "cac_path_parity will be compiled against lsphp${PHPVER} $RTV"
## Build, then RUN THE .phpt SUITE as a build gate. Before this, ext/…/tests/
## existed but nothing ever executed it — neither this Dockerfile nor
## .gitea/workflows/build-push.yaml — so six green tests were documentation. It
## costs ~1s per PHP version and it runs against the SAME lsphp build the .so
## will ship next to.
##
## Two guards around `make test`, because its default failure mode is silence:
## - if PHP_EXECUTABLE is missing the Makefile prints "Cannot run tests without
## CLI sapi." and EXITS 0. The lsphp packages do ship a real CLI
## (php-config --php-binary => .../bin/phpN.N), but assert it rather than
## trusting it.
## - a run that executes ZERO tests also exits 0, so assert the summary shows
## every .phpt in the directory both ran and passed. Same reasoning as the
## `lsphp -i` probe below: an assertion that cannot fail is worse than none.
COPY ./ext/cac-path-parity /usr/src/cac-path-parity
RUN set -e; \
cd /usr/src/cac-path-parity; \
/usr/local/lsws/lsphp${PHPVER}/bin/phpize; \
./configure --enable-cac-path-parity \
--with-php-config=/usr/local/lsws/lsphp${PHPVER}/bin/php-config; \
make -j"$(nproc)"; \
PHP_BIN=$(/usr/local/lsws/lsphp${PHPVER}/bin/php-config --php-binary); \
if [ ! -x "$PHP_BIN" ]; then \
echo "FATAL: no CLI php at '$PHP_BIN' — \`make test\` would print" >&2; \
echo " 'Cannot run tests without CLI sapi.' and exit 0." >&2; \
exit 1; \
fi; \
EXPECTED=$(ls tests/*.phpt | wc -l); \
if [ "$EXPECTED" -lt 1 ]; then echo "FATAL: no .phpt tests found" >&2; exit 1; fi; \
if ! NO_INTERACTION=1 REPORT_EXIT_STATUS=1 make test >/tmp/make-test.log 2>&1; then \
cat /tmp/make-test.log >&2; \
echo "FATAL: cac_path_parity .phpt suite FAILED — not shipping this .so." >&2; \
exit 1; \
fi; \
cat /tmp/make-test.log; \
if ! grep -Eq "^Number of tests : +${EXPECTED} +${EXPECTED} *$" /tmp/make-test.log; then \
echo "FATAL: expected all ${EXPECTED} .phpt tests to run; the summary above disagrees." >&2; \
exit 1; \
fi; \
if ! grep -Eq "^Tests failed +: +0 " /tmp/make-test.log; then \
echo "FATAL: run-tests.php reported failures." >&2; \
exit 1; \
fi; \
echo "cac_path_parity: ${EXPECTED}/${EXPECTED} .phpt tests passed"; \
cp modules/cac_path_parity.so /build-out/
## ---- stage 2: the shipped sidecar image ------------------------------------
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
ARG PHPVER=83
ENV PHPVER=${PHPVER}
## Match the cac-litespeed extension surface exactly: the only ext the prebuilt
## base lacks is lsphpNN-ldap. setpriv (util-linux) is already on the Ubuntu
## base; we add nothing else the sidecar doesn't need. All apt cache cleaned in
## the same layer to keep the image small.
##
## VERSION LOCKSTEP: `apt-get install lsphpNN-ldap` pulls lsphpNN-common forward,
## which drags the whole lsphpNN family to the repo's current release — the same
## upgrade the ext-build stage gets. Left implicit, the two stages resolve that
## independently and Docker caches them independently, so they drift apart (see
## the long comment on stage 1). Copying stage 1's recorded version in BEFORE
## this layer makes the version part of this layer's cache key: same version =>
## cache hit, new version => this layer re-runs and lands on the same one. The
## explicit `=$V` pins then make a mid-build repo roll a LOUD apt failure instead
## of a silent skew. Verified satisfiable on PHP 8.1/8.3/8.5 (2026-08-05).
##
## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh upgrades the Ubuntu OS
## packages and every other lsphp${PHPVER}* package (extensions such as -msgpack
## are NOT dragged along by the -common pin) to the repos' current versions, and
## fails the build if the repo is unreachable. CACHE_BUST makes this layer a
## guaranteed cache miss per CI run; EXPECT_* are CI's repo-index lookup,
## asserted by the script and written into the image labels below.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
V=$(cat /etc/cac-lsphp-build.version); \
if ! /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \
ca-certificates \
lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \
echo "FATAL: lsphp-apt-upgrade.sh failed (reason above). If apt said" >&2; \
echo " \"Version '$V' ... was not found\" or refused a downgrade: $V is what" >&2; \
echo " cac_path_parity was compiled against, but the LiteSpeed repo (which" >&2; \
echo " keeps only the current release) now offers a newer one — either it" >&2; \
echo " published mid-build or ext-build was a stale cache hit. Re-run the" >&2; \
echo " build (CI) / rebuild with --no-cache (local)." >&2; \
exit 1; \
fi; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
CMV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-common); \
if [ "$RTV" != "$V" ] || [ "$CMV" != "$V" ]; then \
echo "FATAL: cac_path_parity.so was compiled against lsphp${PHPVER} $V but this" >&2; \
echo " image would ship lsphp${PHPVER}=$RTV / -common=$CMV." >&2; \
echo " Rebuild with --no-cache so both stages resolve the same release." >&2; \
exit 1; \
fi; \
echo "runtime lsphp${PHPVER} pinned to $V (the version cac_path_parity was built against)"
## Scripts + the SHARED production lsphp ini (reused verbatim from the litespeed
## image — same runtime, same tuning). Scripts layer last (they change most).
COPY ./scripts/entrypoint-lsphp.sh \
./scripts/detect-memory-lsphp.sh \
./scripts/healthcheck-lsphp.sh \
./scripts/cac-lsphp-normalize.php \
/scripts/
RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scripts/healthcheck-lsphp.sh
## Apply production lsphp ini overrides into lsphp's scan dir (path varies by
## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed).
COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini
RUN bash -c 'set -e; \
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$SCAN_DIR"; \
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
echo "wrote overrides to $SCAN_DIR"'
## Install the cac_path_parity extension into lsphp's own extension_dir and load
## it unconditionally. It is INERT until the entrypoint writes the per-site
## cac_path_parity.from/.to mapping, so it is safe in any context (including
## wp-cli runs, where $_SERVER carries no filesystem paths).
##
## The trailing `lsphp -i | grep` is a BUILD-TIME ASSERTION: if the .so fails to
## load (ABI drift after a base-image PHP bump, bad build) the image build fails
## here rather than shipping a sidecar that silently lost path parity. Note its
## limit: it proves the .so LOADS, not that it was built against these exact
## structs — silent layout drift would sail straight through. The version lockstep
## above is what actually removes that possibility; this stays as the backstop.
## NOTE: probe lsphp with `-i` ONLY. The lsphp binary is the LSAPI SAPI, not the
## CLI — it accepts just -[b|c|n|h|i|q|s|v|?] and answers anything else (`-m`,
## `-r`) by printing its usage text and exiting 0. A `lsphp -m | grep` check
## therefore never matches AND never fails, which is exactly the kind of silent
## always-false assertion this whole change exists to eliminate.
COPY --from=ext-build /build-out/cac_path_parity.so /tmp/cac_path_parity.so
RUN bash -c 'set -e; \
LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"; \
EXT_DIR=$("$LSPHP" -i 2>/dev/null | awk -F" => " "/^extension_dir/ {print \$2; exit}"); \
SCAN_DIR=$("$LSPHP" -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$EXT_DIR" "$SCAN_DIR"; \
mv /tmp/cac_path_parity.so "$EXT_DIR/"; \
printf "; installed by Dockerfile.lsphp\nextension=cac_path_parity.so\n" \
> "$SCAN_DIR/00-cac-path-parity.ini"; \
"$LSPHP" -i 2>/dev/null | grep -q "^cac_path_parity support => enabled$"; \
echo "cac_path_parity installed into $EXT_DIR and verified loadable"'
## php-lsapi gates .user.ini parsing behind this env var (see entrypoint-lsphp.sh
## for the full explanation). Set here so the value is visible in `docker inspect`
## and survives an entrypoint override; the entrypoint re-exports it with the same
## default so the runuser exec path can't drop it.
ENV LSPHP_ENABLE_USER_INI=on
## Inspect without running the image:
## docker buildx imagetools inspect <ref> --format '{{json .Image.Config.Labels}}'
## Values come from CI's repo-index lookup and were asserted against the
## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build).
LABEL org.whp.php-version="${EXPECT_PHP_VERSION:-unverified}" \
org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \
org.whp.php-flavour="lsphp${PHPVER}"
EXPOSE 9000
## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD /scripts/healthcheck-lsphp.sh
ENTRYPOINT ["/scripts/entrypoint-lsphp.sh"]