Add cac-litespeed image family (OpenLiteSpeed, native LSAPI)

New paid-tier per-customer image built on litespeedtech/openlitespeed:1.8.4-lsphpNN.
Matrix: 8.1-8.5. Native LSAPI suexec to customer uid, server-level LSCache,
all WP/WooCommerce extensions (memcached, redis, imagick, mbstring, etc.) baked in.

Files:
- Dockerfile.litespeed (FROM prebuilt LiteSpeed base, layers wp-cli/composer/mariadb)
- configs/litespeed/{httpd_config,site-template,lsphp-overrides}.tpl
- scripts/{entrypoint,create-vhost,detect-memory}-litespeed.sh + install-lscache-wp.sh

CI: new Build-LiteSpeed-Images matrix job. OLS_VERSION pinned to 1.8.4 (only
release with prebuilt images for all 5 PHP versions on Docker Hub).

Spec: whp/docs/superpowers/specs/2026-06-01-cac-litespeed-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:32:47 -07:00
parent 1756d496e5
commit 55c28a0c11
10 changed files with 711 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
## create-vhost-litespeed.sh — sets up OLS config for one customer site.
##
## Approach: keep the stock LiteSpeed-shipped httpd_config.conf VERBATIM
## (it has all the cgid/lscgid plumbing that lscgid needs to actually
## create its IPC socket), and just APPEND our listeners + vhTemplate.
## The custom vhost template lives at conf/templates/site.conf and points
## at /home/${user}/public_html. envsubst renders our user/domain into
## both files at container start.
##
## Expects in env: user, domain, serveralias (optional).
set -euo pipefail
TPL_DIR=${TPL_DIR:-/etc/lsws-templates}
LSWS_CONF=/usr/local/lsws/conf
## Ensure the conf dir has stock config to append to. On first boot with
## a fresh image this is a no-op (image ships with conf/ populated). With
## a future volume mount of conf/, the upstream entrypoint pattern would
## copy from .conf/* — keep parity:
if [ -z "$(ls -A -- "$LSWS_CONF/" 2>/dev/null)" ]; then
cp -R /usr/local/lsws/.conf/* "$LSWS_CONF/"
fi
## Build the serveralias suffix for vhDomain. Empty for none, else
## ",alias1,alias2" prepended to the comma list.
vhost_map_aliases=""
if [ -n "${serveralias:-}" ]; then
for alias in $(echo "$serveralias" | tr ',' ' '); do
[ -z "$alias" ] && continue
vhost_map_aliases="${vhost_map_aliases},${alias}"
done
fi
export vhost_map_aliases user domain
## --- prep the stock httpd_config.conf before appending ours ---
## Stock ships with `listener HTTP {*:80}`, `listener HTTPS {*:443}`, and
## a `vhTemplate docker` mapped to /var/www/vhosts/$VH_NAME/html — these
## conflict with our ports and would shadow our siteVH vhost. Strip them
## and the demo `virtualHost Example`, but KEEP `listener Default` (it's
## bound to 8088 — harmless internally, removing risks unrelated breakage).
## Always restart from a stock copy so re-runs are idempotent (otherwise
## a second sed pass on already-stripped config corrupts it).
cp /usr/local/lsws/.conf/httpd_config.conf "$LSWS_CONF/httpd_config.conf"
## Strip the stock blocks we replace. Use awk: easier than sed range-deletes
## to skip a NAMED block of arbitrary length terminated by a top-level `}`.
awk '
BEGIN { skip = 0 }
/^listener HTTP \{/ || /^listener HTTPS \{/ || /^vhTemplate docker \{/ { skip = 1; next }
skip && /^\}/ { skip = 0; next }
!skip { print }
' "$LSWS_CONF/httpd_config.conf" > "$LSWS_CONF/httpd_config.conf.new"
mv "$LSWS_CONF/httpd_config.conf.new" "$LSWS_CONF/httpd_config.conf"
## --- append our listeners + vhTemplate ---
SENTINEL="## ---- cac-litespeed append (do not edit below) ----"
{
echo ""
echo "$SENTINEL"
envsubst '${user} ${domain} ${vhost_map_aliases}' < "$TPL_DIR/httpd_config.tpl"
} >> "$LSWS_CONF/httpd_config.conf"
## --- write our vhost template to /usr/local/lsws/conf/templates/site.conf ---
envsubst '${user}' < "$TPL_DIR/site-template.tpl" \
> "$LSWS_CONF/templates/site.conf"
## --- per-vhost config file the vhTemplate will reference ---
## OLS creates conf/vhosts/$VH_NAME/ at template-instantiation time, but
## we pre-create it to satisfy the configFile path and write a minimal
## vhconf.conf (empty body — all real config is inline in the template's
## virtualHostConfig{} block).
mkdir -p "$LSWS_CONF/vhosts/siteVH"
echo "## auto-generated; real vhost config is in templates/site.conf" \
> "$LSWS_CONF/vhosts/siteVH/vhconf.conf"
## Permissions: OLS reads conf/ as lsadm. Don't break that.
chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
## detect-memory-litespeed.sh — sibling to detect-memory.sh.
## Computes LSAPI_CHILDREN + extprocessor memSoftLimit/memHardLimit from
## container memory cap. Sourced by entrypoint-litespeed.sh.
## ---- container memory detection (mirrors detect-memory.sh) ----
CONTAINER_MEMORY_BYTES=""
if [ -f /sys/fs/cgroup/memory.max ]; then
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
if [ "$val" != "max" ] && [ -n "$val" ]; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
if [ -n "$mem_kb" ]; then
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
fi
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
## ---- budget split (LSAPI workers get the lion's share) ----
OS_RESERVE_MB=50
OLS_RESERVE_MB=40 # OpenLiteSpeed daemon footprint
DEV_OVERHEAD_MB=0
if [ "${environment:-PROD}" = "DEV" ]; then
DEV_OVERHEAD_MB=125
fi
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - OLS_RESERVE_MB - DEV_OVERHEAD_MB))
if [ "$AVAILABLE_MB" -lt 60 ]; then
AVAILABLE_MB=60
fi
## ---- LSAPI children (analogous to PHP_FPM_MAX_CHILDREN) ----
## LSAPI is more memory-efficient than FPM; estimate 96 MB / worker
## (vs 128 MB for FPM after the 2026-06-01 bump). Floor 2, cap 50.
LSPHP_WORKER_ESTIMATE_MB=${LSPHP_WORKER_ESTIMATE_MB:-96}
calc_lsapi_children=$((AVAILABLE_MB / LSPHP_WORKER_ESTIMATE_MB))
if [ "$calc_lsapi_children" -lt 2 ]; then
calc_lsapi_children=2
fi
if [ "$calc_lsapi_children" -gt 50 ]; then
calc_lsapi_children=50
fi
## Per-site override knobs — site-pool-env.php still passes FPM_MAX_CHILDREN
## for backward compat, so prefer LSAPI_CHILDREN if set, else FPM_MAX_CHILDREN,
## else the calculated value.
LSAPI_CHILDREN=${LSAPI_CHILDREN:-${FPM_MAX_CHILDREN:-$calc_lsapi_children}}
## extprocessor mem limits — total LSAPI heap should fit AVAILABLE_MB with
## some breathing room. Soft = budget/children, hard = soft * 1.5, both capped
## at 2047 (OLS interprets > 2047 oddly in some 1.x builds).
LSAPI_MEM_SOFT=$((AVAILABLE_MB / LSAPI_CHILDREN))
if [ "$LSAPI_MEM_SOFT" -lt 64 ]; then LSAPI_MEM_SOFT=64; fi
if [ "$LSAPI_MEM_SOFT" -gt 2047 ]; then LSAPI_MEM_SOFT=2047; fi
LSAPI_MEM_HARD=$((LSAPI_MEM_SOFT * 3 / 2))
if [ "$LSAPI_MEM_HARD" -gt 2047 ]; then LSAPI_MEM_HARD=2047; fi
export CONTAINER_MEMORY_MB LSAPI_CHILDREN LSAPI_MEM_SOFT LSAPI_MEM_HARD

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
## entrypoint-litespeed.sh — PID 1 for cac-litespeed:phpNN.
## Built on litespeedtech/openlitespeed:1.8.x-lsphp83 prebuilt base. Native
## LSAPI (no FPM proxy), one customer per container.
##
## Process supervision: starts OLS via `openlitespeed -n` (no-daemon +
## crash-guard, per OLS source: lshttpdmain.cpp). SIGTERM is forwarded.
## crond runs in the background for customer crontabs; OLS itself is the
## process we wait on (if OLS dies, the container exits and Docker
## restarts it per its restart policy).
set -euo pipefail
: "${PHPVER:=83}"
: "${environment:=PROD}"
: "${LSCACHE_AUTOINSTALL:=1}"
export CONTAINER_ROLE="litespeed_only"
export PHPVER environment LSCACHE_AUTOINSTALL
## ---- env validation ----
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
exit 1
fi
: "${domain:=localhost}"
export user domain
## ---- user + directories ----
if ! id -u "$user" >/dev/null 2>&1; then
## Ubuntu's useradd; mirror what the AL10 entrypoints do with adduser
useradd -u "$uid" -m -s /bin/bash "$user"
fi
mkdir -p "/home/$user/public_html"
mkdir -p "/home/$user/logs/litespeed"
mkdir -p "/home/$user/lscache"
mkdir -p /tmp/lshttpd/swap
chmod 1777 /tmp/lshttpd
## ---- memory + lsphp pool sizing ----
# shellcheck source=/dev/null
source /scripts/detect-memory-litespeed.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | LSAPI_CHILDREN=${LSAPI_CHILDREN} | memSoft=${LSAPI_MEM_SOFT}M memHard=${LSAPI_MEM_HARD}M | PHPVER=${PHPVER}"
## ---- self-signed cert (idempotent) ----
mkdir -p /usr/local/lsws/conf/cert
if [ ! -f /usr/local/lsws/conf/cert/self.crt ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /usr/local/lsws/conf/cert/self.key \
-out /usr/local/lsws/conf/cert/self.crt \
-subj "/CN=${domain}" 2>/dev/null
fi
## ---- render httpd_config + vhconf from templates ----
/scripts/create-vhost-litespeed.sh
## ---- ownership: OLS master/workers run as nobody; lsphp suexecs to the
## customer per request (setUIDMode 2 in httpd_config.tpl). So the customer
## owns everything under /home/$user — clean ownership model, no nobody
## chowning. OLS's own runtime dirs stay nobody-owned.
chown -R nobody:nogroup /usr/local/lsws/logs /usr/local/lsws/conf/cert /tmp/lshttpd 2>/dev/null || true
chown -R "$user:$user" "/home/$user"
chmod 755 "/home/$user"
## ---- drop healthz so docker HEALTHCHECK passes before customer files
## Always rewrite as customer; suexec lsphp will read it as that uid too.
sudo -u "$user" sh -c "echo ok > /home/$user/public_html/healthz"
## ---- DEV: local mariadb + memcached for parity with cac entrypoints ----
if [ "$environment" = "DEV" ]; then
echo "Starting Dev Deployment (litespeed)"
mkdir -p "/home/$user/_db_backups"
## mariadb-server + memcached are already in the image (apt-installed
## in the Dockerfile). Just start them.
mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld
nohup mysqld --user=mysql &>/dev/null &
if [ ! -f "/home/$user/mysql_creds" ]; then
sleep 10
mysql_user=$(openssl rand -hex 7)
mysql_password=$(openssl rand -hex 12)
mysql_db="devdb_$(openssl rand -hex 3)"
mysql -e "CREATE DATABASE $mysql_db;"
mysql -e "CREATE USER '$mysql_user'@'localhost' IDENTIFIED BY '$mysql_password';"
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '$mysql_user'@'localhost' WITH GRANT OPTION;"
mysql -e "FLUSH PRIVILEGES;"
{
echo "MySQL User: $mysql_user"
echo "MySQL Password: $mysql_password"
echo "MySQL Database: $mysql_db"
} > "/home/$user/mysql_creds"
cat "/home/$user/mysql_creds"
fi
/usr/bin/memcached -d -u "$user"
fi
## ---- user crontab ----
if [ ! -f "/home/$user/crontab" ]; then
{
echo "# User crontab for $user"
echo "# Add your cron jobs here"
} > "/home/$user/crontab"
chown "$user:$user" "/home/$user/crontab"
fi
crontab -u "$user" "/home/$user/crontab"
service cron start >/dev/null 2>&1 || /usr/sbin/cron
## ---- LSCache plugin (background, non-fatal) ----
( /scripts/install-lscache-wp.sh "$user" >>/var/log/lscache-install.log 2>&1 || true ) &
## ---- start OLS in foreground with crash-guard ----
## openlitespeed -n = no-daemon + supervisor. Trap forwards SIGTERM cleanly.
OLS_PID=""
trap '[ -n "$OLS_PID" ] && kill -TERM "$OLS_PID" 2>/dev/null; wait "$OLS_PID" 2>/dev/null || true' TERM INT
/usr/local/lsws/bin/openlitespeed -n &
OLS_PID=$!
## Stream OLS + customer logs to PID-1 stdout so `docker logs` works.
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
touch "/home/$user/logs/litespeed/error.log" "/home/$user/logs/litespeed/access.log"
tail -F /usr/local/lsws/logs/error.log \
/usr/local/lsws/logs/access.log \
"/home/$user/logs/litespeed/error.log" \
"/home/$user/logs/litespeed/access.log" 2>/dev/null &
wait "$OLS_PID"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
## install-lscache-wp.sh — auto-install the official litespeed-cache plugin
## on first boot if WP is detected and plugin not already managed.
## Idempotent: re-runs are no-ops. Honors LSCACHE_AUTOINSTALL=0 escape hatch.
##
## Args: $1 = $user (customer system user)
set -euo pipefail
user="${1:?usage: install-lscache-wp.sh <user>}"
home="/home/${user}"
if [ "${LSCACHE_AUTOINSTALL:-1}" = "0" ]; then
echo "[lscache] LSCACHE_AUTOINSTALL=0 — skipping plugin install."
exit 0
fi
if [ ! -f "$home/public_html/wp-config.php" ]; then
echo "[lscache] No wp-config.php in $home/public_html — skipping (not a WP site)."
exit 0
fi
## With setUIDMode 2, lsphp runs as the customer, and customer owns their
## home tree — wp-cli also runs as the customer, files end up correctly owned.
if ! command -v wp >/dev/null 2>&1; then
echo "[lscache] wp-cli not on PATH — skipping (image build issue, not fatal)."
exit 0
fi
if sudo -u "$user" -- wp --path="$home/public_html" plugin is-installed litespeed-cache 2>/dev/null; then
echo "[lscache] litespeed-cache already installed — leaving customer's settings alone."
exit 0
fi
echo "[lscache] Installing litespeed-cache plugin for $user"
sudo -u "$user" -- wp --path="$home/public_html" plugin install litespeed-cache --activate \
|| { echo "[lscache] plugin install failed (network? wp-cli? perms?) — non-fatal."; exit 0; }
echo "[lscache] Done."