feat(shared-ols): shared OpenLiteSpeed tier image (webserver-only, fronts cac-lsphp sidecars)

One OLS container fronting many tenants' detached cac-lsphp sidecars — the
OLS analogue of shared-httpd. Runs NO PHP locally; every site's PHP goes to
its own sidecar over LSAPI (extProcessor type lsapi, address <sidecar>:9000).

Key design fact (established by PoC): OLS has NO top-level 'include' directive,
so render-shared-ols-config.sh assembles httpd_config.conf from the panel's
per-site files (vhconf.conf + site.meta) at boot and on every change — the
'include' OLS lacks. Per-site detail uses the OLS-native configFile +
vhost-scoped extprocessor model. LSCache is module-level (a configFile-loaded
vhost rejects a bare cache{} block); the WP LiteSpeed plugin controls
cacheability via X-LiteSpeed-Cache-Control headers.

- Dockerfile.shared-ols: litespeed base + inotify-tools/envsubst/openssl,
  admin bound to loopback, :80/:443 self-signed, healthz HEALTHCHECK.
- entrypoint-shared-ols.sh: cert + health vhost + render + watcher, then
  daemon-mode OLS supervision (reused from cac-litespeed so self-restarts
  don't kill PID 1).
- render-shared-ols-config.sh: strip stock (incl local lsphp) + append base +
  per-site stanzas + listeners with all maps + catch-all health vhost.
- ols-htaccess-watcher.sh: inotify debounce+floor -> lswsctrl restart (spec 5.3).
- configs/shared-ols/{httpd_config_base,vhconf}.tpl.
- CI: Build-Shared-OLS job.

Verified locally end-to-end: zero-site boot healthy on :443; add site via the
panel contract -> Host-routed to the right sidecar (SAPI=litespeed); real
client IP + HTTPS behind X-Forwarded headers; LSCache miss->hit; .htaccess
change triggers graceful restart; unknown Host hits health catch-all (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:22:14 -07:00
parent 19092911a3
commit 19db8f170a
7 changed files with 501 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
## entrypoint-shared-ols.sh — PID 1 for the shared-ols tier.
##
## One OpenLiteSpeed container fronting MANY tenants' detached cac-lsphp
## sidecars (the OLS analogue of the shared-httpd container). Webserver ONLY —
## it runs NO PHP locally (render-shared-ols-config.sh strips the stock local
## lsphp; every site's PHP goes to its own sidecar over LSAPI). HAProxy stays
## the TLS/WAF/SNI edge and routes OLS-type hostnames here on :443.
##
## Reuses cac-litespeed's hard-won DAEMON-MODE supervision (NOT `openlitespeed
## -n` + wait): OLS self-restarts on QUIC.cloud IP refresh would otherwise exit
## PID 1 cleanly and tear the container down. See entrypoint-litespeed.sh and
## feedback_ols_quiccloud_restart_kills_container.
set -euo pipefail
: "${environment:=PROD}"
export CONTAINER_ROLE="shared_ols"
LSWS_CONF=/usr/local/lsws/conf
CERT_DIR="$LSWS_CONF/cert"
HEALTH_DIR=/usr/local/lsws/shared-ols-health
export SITES_ROOT="${SITES_ROOT:-$LSWS_CONF/shared-sites}"
export LSCACHE_ROOT="${LSCACHE_ROOT:-/var/lscache}"
export CERT_FILE="$CERT_DIR/shared-ols.crt"
export KEY_FILE="$CERT_DIR/shared-ols.key"
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" "$CERT_DIR" "$HEALTH_DIR/html"
## ---- self-signed cert for the :443 listener (HAProxy verifies none) ----
if [ ! -f "$CERT_FILE" ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$KEY_FILE" -out "$CERT_FILE" -subj "/CN=shared-ols" 2>/dev/null
fi
## ---- health vhost (catch-all): valid server with zero customer sites +
## answers HAProxy health checks that hit by IP / unknown Host with a 200 ----
cat > "$HEALTH_DIR/vhconf.conf" <<'EOF'
docRoot $VH_ROOT/html
enableScript 0
context / {
allowBrowse 1
location $DOC_ROOT/
}
EOF
printf 'ok\n' > "$HEALTH_DIR/html/healthz"
printf 'shared-ols\n' > "$HEALTH_DIR/html/index.html"
## ---- assemble httpd_config.conf from the panel's per-site files ----
/scripts/render-shared-ols-config.sh
chown -R lsadm:nogroup "$LSWS_CONF" "$HEALTH_DIR" 2>/dev/null || true
## ---- stream OLS logs to PID-1 stdout (follows across restarts) ----
mkdir -p /usr/local/lsws/logs
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
tail -F /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log 2>/dev/null &
## ---- .htaccess watcher (required; spec 5.3). Background; the panel monitors
## that it stays alive (its death silently stops rewrite changes applying). ----
/scripts/ols-htaccess-watcher.sh &
WATCHER_PID=$!
## ---- supervise OLS in DAEMON mode (verbatim model from entrypoint-litespeed.sh) ----
STOP_REQUESTED=0
term_handler() {
STOP_REQUESTED=1
kill "$WATCHER_PID" 2>/dev/null || true
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
}
trap term_handler TERM INT
ols_running() { /usr/local/lsws/bin/lswsctrl status 2>/dev/null | grep -qi 'running with pid'; }
MAX_STARTS=5
WINDOW=60
starts=""
start_ols() {
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
ols_running && return 0
sleep 0.5
done
return 1
}
if ! start_ols; then
echo "entrypoint-shared-ols: OLS failed to start (not running after 10s)." >&2
exit 1
fi
echo "entrypoint-shared-ols: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
while true; do
if ols_running; then
sleep 3
continue
fi
sleep 2
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
continue
fi
if [ "$STOP_REQUESTED" -eq 1 ]; then
echo "entrypoint-shared-ols: SIGTERM received, OLS stopped — exiting."
exit 0
fi
now=$(date +%s)
starts="$starts $now"
pruned=""
for t in $starts; do
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
done
starts="$pruned"
n=$(echo $starts | wc -w)
echo "entrypoint-shared-ols: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
if [ "$n" -ge "$MAX_STARTS" ]; then
echo "entrypoint-shared-ols: OLS crash-looping — bailing for Docker restart policy / monitor." >&2
exit 1
fi
start_ols || true
done

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
## ols-htaccess-watcher.sh — graceful-restart the shared OLS when any tenant's
## .htaccess changes. OLS reads .htaccess (RewriteFile) at (re)start, NOT per
## request, so without this a WordPress permalink/LiteSpeed-Cache change would
## silently not take effect. Required by spec 5.3.
##
## Watches all docroots for .htaccess writes, COALESCES bursts (a WP plugin save
## touches the file several times) within a debounce window, and RATE-LIMITS to
## a floor (one restart per FLOOR seconds) so many tenants saving at once can't
## trigger a restart storm. Debounce/floor are env-tunable (panel discloses the
## resulting "~60s" window to customers).
##
## Failure of THIS process is the silent-ticket failure mode (spec 7): if it
## dies, tenants' rewrite changes stop applying with no error. The entrypoint
## runs it and the panel monitors it (check-ols-htaccess-watcher.php).
set -uo pipefail
WATCH_ROOT="${OLS_WATCH_ROOT:-/mnt/users}"
DEBOUNCE="${OLS_HTACCESS_DEBOUNCE:-15}" # coalesce window (s)
FLOOR="${OLS_HTACCESS_FLOOR:-60}" # min seconds between restarts
LSWSCTRL=/usr/local/lsws/bin/lswsctrl
last_restart=0
log() { echo "ols-htaccess-watcher: $*" >&2; }
do_restart() {
now=$(date +%s)
if [ $((now - last_restart)) -lt "$FLOOR" ]; then
log "within ${FLOOR}s floor — coalescing, skipping restart"
return
fi
if "$LSWSCTRL" restart >/dev/null 2>&1; then
last_restart=$now
log "graceful restart issued (.htaccess change)"
else
log "WARNING: lswsctrl restart failed"
fi
}
if ! command -v inotifywait >/dev/null 2>&1; then
log "FATAL: inotifywait not installed (inotify-tools)"; exit 1
fi
mkdir -p "$WATCH_ROOT"
log "watching $WATCH_ROOT for .htaccess changes (debounce=${DEBOUNCE}s floor=${FLOOR}s)"
## -m monitor, -r recursive. We filter to .htaccess in the read loop rather than
## --include so this works on older inotify-tools too. modify/create/delete/move
## all matter (delete of .htaccess also changes rewrite behavior).
inotifywait -m -r -e modify,create,delete,move "$WATCH_ROOT" --format '%f' 2>/dev/null |
while read -r fname; do
case "$fname" in
.htaccess) ;;
*) continue ;;
esac
## Drain further events for DEBOUNCE seconds (coalesce the burst), then act.
while read -r -t "$DEBOUNCE" _; do :; done
do_restart
done

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
## render-shared-ols-config.sh — assemble httpd_config.conf for the shared-ols
## tier from the per-site files the WHP panel drops into $SITES_ROOT.
##
## WHY THIS EXISTS: OpenLiteSpeed has NO top-level `include` directive (unlike
## Apache's IncludeOptional that shared-httpd relies on). So we cannot just drop
## per-vhost files in a dir and have OLS pick them up — the listener `map` lines
## and the vhost stanzas must live IN httpd_config.conf. This script is the
## "include" OLS lacks: it concatenates the panel's per-site pieces into one
## valid httpd_config.conf, then the caller issues `lswsctrl restart`.
## (Empirically established 2026-06-10 — see the OLS-tier PoC.)
##
## Per-site contract — the panel writes, for each site, a directory:
## $SITES_ROOT/<vhname>/vhconf.conf (rendered from configs/shared-ols/vhconf.tpl)
## $SITES_ROOT/<vhname>/site.meta (shell: VHNAME, VHROOT, DOMAINS="a.com,www.a.com")
## This script turns each into a `virtualhost {configFile}` stanza + a listener
## `map` line. A site dir missing either file is skipped (logged).
##
## Idempotent: always rebuilds from the stock config, so re-runs never compound.
set -euo pipefail
LSWS_CONF=/usr/local/lsws/conf
TPL_DIR=${TPL_DIR:-/etc/shared-ols-templates}
SITES_ROOT=${SITES_ROOT:-$LSWS_CONF/shared-sites}
LSCACHE_ROOT=${LSCACHE_ROOT:-/var/lscache}
CERT_FILE=${CERT_FILE:-$LSWS_CONF/cert/shared-ols.crt}
KEY_FILE=${KEY_FILE:-$LSWS_CONF/cert/shared-ols.key}
export LSCACHE_ROOT
OUT="$LSWS_CONF/httpd_config.conf"
STOCK="/usr/local/lsws/.conf/httpd_config.conf"
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT"
## --- 1. start from a pristine stock config (idempotent) ---
if [ ! -f "$STOCK" ]; then
## Some image builds keep the only copy at conf/; snapshot it once so future
## renders have a clean base to strip.
mkdir -p "$(dirname "$STOCK")"
cp "$OUT" "$STOCK"
fi
## --- 2. strip stock blocks that conflict or would run PHP LOCALLY ---
## extProcessor lsphp (autoStart 1, uds) + the server scriptHandler are removed
## so this server NEVER executes PHP itself — all PHP goes to remote sidecars.
## listener HTTP/HTTPS + vhTemplate docker are removed (we add our own).
awk '
/^listener HTTP \{/ { skip=1; next }
/^listener HTTPS \{/ { skip=1; next }
/^vhTemplate docker ?\{/ { skip=1; next }
/^extProcessor lsphp ?\{/{ skip=1; next }
/^scriptHandler ?\{/ { skip=1; next }
skip && /^\}/ { skip=0; next }
!skip { print }
' "$STOCK" > "$OUT"
## --- 3. append our server-level base (real-IP, cache module, no local PHP) ---
{
echo ""
envsubst '${LSCACHE_ROOT}' < "$TPL_DIR/httpd_config_base.tpl"
} >> "$OUT"
## --- 4. emit per-site vhost stanzas + collect listener map lines ---
maps=""
site_count=0
for meta in "$SITES_ROOT"/*/site.meta; do
[ -e "$meta" ] || continue
sdir=$(dirname "$meta")
VHNAME=""; VHROOT=""; DOMAINS=""
# shellcheck source=/dev/null
. "$meta"
if [ -z "$VHNAME" ] || [ -z "$VHROOT" ] || [ -z "$DOMAINS" ] || [ ! -f "$sdir/vhconf.conf" ]; then
echo "render-shared-ols: skipping $sdir (incomplete: VHNAME/VHROOT/DOMAINS/vhconf.conf)" >&2
continue
fi
{
echo ""
echo "virtualhost ${VHNAME} {"
echo " vhRoot ${VHROOT}"
echo " configFile ${sdir}/vhconf.conf"
echo " allowSymbolLink 1"
echo " enableScript 1"
echo " restrained 1"
echo "}"
} >> "$OUT"
maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n'
site_count=$((site_count + 1))
done
## --- 5. ALWAYS add a health vhost mapped to the catch-all so the server is
## valid with zero customer sites and HAProxy health checks (which hit by IP /
## unknown Host) get a 200. Exact-domain maps above win over this '*'. ---
{
echo ""
echo "virtualhost _health {"
echo " vhRoot /usr/local/lsws/shared-ols-health"
echo " configFile /usr/local/lsws/shared-ols-health/vhconf.conf"
echo " allowSymbolLink 1"
echo " enableScript 0"
echo "}"
} >> "$OUT"
maps="${maps} map _health *"$'\n'
## --- 6. listeners (HTTP :80 + HTTPS :443 self-signed) carrying ALL maps.
## HAProxy terminates real TLS and connects to this tier on :443 ssl verify
## none (same as shared-httpd), so :443 needs a cert — self-signed is fine. ---
{
echo ""
echo "listener shared_http {"
echo " address *:80"
echo " secure 0"
printf '%s' "$maps"
echo "}"
echo ""
echo "listener shared_https {"
echo " address *:443"
echo " secure 1"
echo " keyFile ${KEY_FILE}"
echo " certFile ${CERT_FILE}"
printf '%s' "$maps"
echo "}"
} >> "$OUT"
chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true
echo "render-shared-ols: wrote $OUT ($site_count customer vhost(s) + health)"