diff --git a/AGENTS.md b/AGENTS.md index 30a4d5e..c540882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,11 @@ first image is `wordpress` (WordPress served by Caddy on PHP-FPM). bypass it via Caddy's `remote_ip` (real peer, never spoofable forwarding headers). Missing/invalid `WORKSPACE_AUTH_*` fails the container closed. Do not weaken this to trust forwarding headers or to add an auth-disabled mode. +- The **Cast** plugin is platform-managed: baked from the latest GitHub + `develop` tree snapshot (Composer deps vendored at build; not checksum-pinned + yet) and force-kept active by an image-owned MU plugin. Plugin/theme editing + is disabled (`DISALLOW_FILE_EDIT`) but wp-admin plugin install/update must + stay available — never reintroduce `DISALLOW_FILE_MODS`. - Database settings come from `WORDPRESS_DB_HOST/PORT/NAME/USER/PASSWORD`. - `COOLIFY_URL` supplies the externally reachable public URL used for `WP_HOME`/`WP_SITEURL` and for `wp core install`; `X-Forwarded-*` from the diff --git a/Makefile b/Makefile index 159d8b9..4a47a4e 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,8 @@ export PHP_BASE PHP_BASE_DIGEST CADDY_VERSION \ CADDY_SHA512_AMD64 CADDY_SHA512_ARM64 \ WORDPRESS_VERSION WORDPRESS_SHA256 \ WP_CLI_VERSION WP_CLI_SHA512 \ - GO_BASE GO_BASE_DIGEST + GO_BASE GO_BASE_DIGEST \ + COMPOSER_BASE COMPOSER_BASE_DIGEST .PHONY: help build build-php-caddy build-wordpress \ lint shellcheck hadolint \ diff --git a/docker-bake.hcl b/docker-bake.hcl index a25e445..b3cba20 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -27,6 +27,8 @@ variable "WP_CLI_VERSION" { default = "" } variable "WP_CLI_SHA512" { default = "" } variable "GO_BASE" { default = "" } variable "GO_BASE_DIGEST" { default = "" } +variable "COMPOSER_BASE" { default = "" } +variable "COMPOSER_BASE_DIGEST" { default = "" } # Publish immutable per-version tags AND a floating `:latest` under the release # path. Controlled by the CD release workflow (.github/workflows/release.yml): @@ -97,6 +99,8 @@ target "wordpress" { WP_CLI_SHA512 = WP_CLI_SHA512 GO_BASE = GO_BASE GO_BASE_DIGEST = GO_BASE_DIGEST + COMPOSER_BASE = COMPOSER_BASE + COMPOSER_BASE_DIGEST = COMPOSER_BASE_DIGEST } labels = { "org.opencontainers.image.base.digest" = PHP_BASE_DIGEST @@ -105,6 +109,8 @@ target "wordpress" { "com.lumeweb.wpcli.version" = WP_CLI_VERSION "com.lumeweb.go-builder.base" = GO_BASE "com.lumeweb.go-builder.digest" = GO_BASE_DIGEST + "com.lumeweb.composer.base" = COMPOSER_BASE + "com.lumeweb.composer.base.digest" = COMPOSER_BASE_DIGEST } tags = concat( VERSION == "" ? [] : ["${REGISTRY}/workspace-wordpress:${VERSION}"], diff --git a/images/wordpress/Dockerfile b/images/wordpress/Dockerfile index 8f68d8b..1f77978 100644 --- a/images/wordpress/Dockerfile +++ b/images/wordpress/Dockerfile @@ -12,7 +12,22 @@ ## -------------------------------------------------------------------------- ARG GO_BASE=workspace-init-builder-unset ARG GO_BASE_DIGEST=sha256:0000000000000000000000000000000000000000000000000000000000000000 +ARG COMPOSER_BASE=cast-builder-unset +ARG COMPOSER_BASE_DIGEST=sha256:0000000000000000000000000000000000000000000000000000000000000000 FROM ${GO_BASE}@${GO_BASE_DIGEST} AS workspace-init-builder + +## -------------------------------------------------------------------------- +## cast plugin builder stage +## +## Vendors the platform-managed Cast plugin from the latest `develop` tree +## snapshot on GitHub (deliberately NOT checksum-pinned yet; evolves later into +## a versioned, verified release artifact). The GitHub tree carries no vendor/ +## directory (it is gitignored), so Composer installs the runtime dependencies +## here new on every build — deterministic through the snapshot's composer.lock. +## The dependency set includes an SSH-style git VCS URL, transparently rewritten +## to HTTPS so a CI build without SSH keys can fetch it. The ARGs are declared +## globally (before the first FROM) so this FROM resolves. +## -------------------------------------------------------------------------- WORKDIR /src # Copy manifests first so `go mod download` is cached independently of source # edits, then build with CGO disabled for a self-contained static binary that @@ -22,6 +37,27 @@ RUN go mod download COPY workspace-init/ ./ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/workspace-init . +## -------------------------------------------------------------------------- +## cast plugin builder stage +## +## Vendors the platform-managed Cast plugin from the latest `develop` tree +## snapshot on GitHub (deliberately NOT checksum-pinned yet; evolves later into +## a versioned, verified release artifact). The GitHub tree carries no vendor/ +## directory (it is gitignored), so Composer installs the runtime dependencies +## here new on every build — deterministic through the snapshot's composer.lock. +## The dependency set includes an SSH-style git VCS URL, transparently rewritten +## to HTTPS so a CI build without SSH keys can fetch it. +## -------------------------------------------------------------------------- +FROM ${COMPOSER_BASE}@${COMPOSER_BASE_DIGEST} AS cast-builder +RUN git config --global url."https://github.com/".insteadOf "git@github.com:"; \ + curl -fsSL -o /tmp/cast.tar.gz \ + "https://codeload.github.com/LumeWeb/cast/tar.gz/refs/heads/develop"; \ + mkdir -p /out/cast; \ + tar -xzf /tmp/cast.tar.gz --strip-components=1 -C /out/cast; \ + rm -f /tmp/cast.tar.gz +WORKDIR /out/cast +RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts + ## -------------------------------------------------------------------------- ## Pinner WordPress image: WordPress served by Caddy on PHP-FPM. ## @@ -87,6 +123,19 @@ RUN set -eux; \ # The workspace-init CLI (owner-email helper) from the builder stage above. COPY --from=workspace-init-builder /out/workspace-init /usr/local/bin/workspace-init +# -- Platform-managed Cast plugin (immutable seed) ---------------------------- +# Baked into the immutable source tree, so wp-init.sh's one-time plugin seeding +# delivers it to fresh plugins volumes like any bundled plugin. Already-seeded +# volumes are NOT overwritten here (user-content preservation); reconciling an +# existing volume with the baked version is a future wp-init step. +COPY --from=cast-builder /out/cast /usr/src/wordpress/wp-content/plugins/cast + +# -- Cast guard MU plugin (force-on enforcement) ------------------------------ +# mu-plugins is always ephemeral (not a persistent mount), so wp-init.sh copies +# this into wp-content/mu-plugins on every boot; the file enforces that Cast +# stays active regardless of what wp-admin does with active_plugins. +COPY mu-plugins/cast-guard.php /usr/src/wordpress/wp-content/mu-plugins/cast-guard.php + # -- startup init hook -------------------------------------------------------- # Runs as root before the base drops privileges: copies core into the ephemeral # docroot, seeds persistent themes/plugins onto (possibly root-owned) volumes, diff --git a/images/wordpress/README.md b/images/wordpress/README.md index 242fa1d..a9a35a7 100644 --- a/images/wordpress/README.md +++ b/images/wordpress/README.md @@ -8,6 +8,7 @@ built on the `php-caddy` base. Uses official WordPress conventions. | Base | `php-caddy` (`php:8.5.10-fpm-bookworm` + Caddy `2.11.4`) | | WordPress | `7.1` (archive sha256 verified) | | WP-CLI | `2.12.0` (baked in, sha512 verified) | +| Cast plugin | latest `develop` tree snapshot, deps vendored at build (not checksum-pinned yet) | | Immutable source | `/usr/src/wordpress` | | Runtime docroot | `/var/www/html` (ephemeral) | | Listen | `0.0.0.0:${PORT:-8080}` | @@ -68,20 +69,24 @@ Runs as root (via the base `PINNER_INIT` hook) before privileges are dropped: exits non-zero (a WordPress workspace without a DB is a real misconfiguration; we refuse to boot broken). 3. **Seed `themes` and `plugins`** from `/usr/src/wordpress/wp-content` onto the - mounted volumes (copy-based, never symlinks), so the default theme and - bundled plugins appear on a brand-new shadowing volume. -4. **Leave `uploads` unseeded** (user media only). -5. **Generate `wp-config.php`** with WP-CLI `wp config create` as `www-data` + mounted volumes (copy-based, never symlinks), so the default theme, bundled + plugins, and the platform-managed **cast** plugin appear on a brand-new + shadowing volume. +4. **Copy `mu-plugins`** (the Cast guard) into the ephemeral `wp-content` on + every boot — it is platform-owned code like core, not persistent content. +5. **Leave `uploads` unseeded** (user media only). +6. **Generate `wp-config.php`** with WP-CLI `wp config create` as `www-data` (mode **0600**, owner-only read; DB password + proxy/URL extra PHP travel on stdin, never argv). -6. **Automatic bootstrap** — wait (bounded) for the DB, then on first boot run +7. **Automatic bootstrap** — wait (bounded) for the DB, then on first boot run `wp core install --url=$COOLIFY_URL` with the owner email (fetched from the portal by the baked-in `workspace-init` CLI) and the existing `WORKSPACE_AUTH_*` credentials; on every boot converge the admin password to - the current `WORKSPACE_AUTH_PASSWORD`. A DB that is merely down, or a portal - that cannot supply the email, defers install to a later boot with a clear - warning rather than inventing a bogus admin email. -7. **Ownership** — repair root-owned mount roots; only recursive-chown when the + the current `WORKSPACE_AUTH_PASSWORD` and converge the platform-managed + **cast** plugin to active (real activation hooks). A DB that is merely down, + or a portal that cannot supply the email, defers install to a later boot + with a clear warning rather than inventing a bogus admin email. +8. **Ownership** — repair root-owned mount roots; only recursive-chown when the mount root is not already owned by `www-data`. ### `wp-config.php` generation (`wp config create`) @@ -106,13 +111,16 @@ than a custom generator: intentionally *not* set (the whole config is ephemeral, no persistent object cache). - **Workspace lockdown** (baked in as hard `define()`s via `--extra-php`): - - `DISALLOW_FILE_EDIT` / `DISALLOW_FILE_MODS` — wp-admin can never edit the - filesystem or install/modify code (the image provisions WordPress; code is - never user-writable through the web UI). + - `DISALLOW_FILE_EDIT` — wp-admin can never edit the filesystem (plugin/theme + editor is dead). Pieces of code that ARE managed (core, the `cast` plugin) + come from the image; everything in `wp-content` stays out of the editor. + - No `DISALLOW_FILE_MODS` — **installing and updating plugins through + wp-admin stays available on purpose**; that is how the site manages its own + plugin set alongside the image-provisioned ones (Cast). - `AUTOMATIC_UPDATER_DISABLED` / `WP_AUTO_UPDATE_CORE` — WordPress never updates itself out of band (its scheduled update hooks become no-ops that find nothing to do; outbound checks to api.wordpress.org remain harmless - reads). + reads). Updates happen only when someone triggers them. - `DISABLE_WP_CRON` — cron is not triggered by web traffic; the supervised worker below ticks it instead. - Config is **ephemeral**: regenerated every start. @@ -161,6 +169,31 @@ Initialization is serialized with `flock` on the shared volume and idempotent: a bounced container or concurrent first boot will not re-seed, and user edits to plugins/themes survive recreation. +## Platform-managed Cast plugin + +The **Cast** plugin is workspace infrastructure (like `wp-config.php` — not +user content), so delivery is image-owned: + +- The image **bakes the latest `develop` tree snapshot** of + [`LumeWeb/cast`](https://github.com/LumeWeb/cast) (GitHub codeload tarball; + deliberately not checksum-pinned yet) with runtime Composer dependencies + vendored at build time, under `/usr/src/wordpress/wp-content/plugins/cast`. +- One-time volume seeding delivers it on fresh volumes. On **already-seeded + volumes** a boot-time reconciler (`reconcile_cast()` in `wp-init.sh`) + converges only `wp-content/plugins/cast` to the image-baked copy on **every + boot** — an image upgrade or rollback delivers the new/older Cast to + *existing* workspaces on their next restart. All other plugins stay + user-owned (never clobbered); the replaced copy is archived as + `plugins/.cast.bak-` (dot-prefixed so WordPress's plugin scan + ignores it; one backup retained) for manual rollback. +- A **Cast guard MU plugin** (`mu-plugins/cast-guard.php`, copied into the + ephemeral `wp-content/mu-plugins` every boot) re-adds `cast/cast.php` to the + active plugins on every read (only while the plugin's files exist, so a + transiently absent directory never gets a phantom active entry) and removes + the admin Deactivate action — the plugin cannot be turned off. Startup + convergence (`wp plugin activate cast`) only performs the real one-time + activation transition (schema install, rewrite flush). + ## Environment | Variable | Purpose | diff --git a/images/wordpress/mu-plugins/cast-guard.php b/images/wordpress/mu-plugins/cast-guard.php new file mode 100644 index 0000000..a96c780 --- /dev/null +++ b/images/wordpress/mu-plugins/cast-guard.php @@ -0,0 +1,101 @@ +/dev/null 2>&1; then + return 0 + fi + if ! run_wp plugin is-installed cast >/dev/null 2>&1; then + echo "WARN: cast plugin files are missing from the plugins volume; cannot activate." >&2 + return 0 + fi + if ! run_wp plugin activate cast >/dev/null 2>&1; then + echo "WARN: could not activate the cast plugin (it stays guarded by the MU layer)." >&2 + fi +} + # One-time seed of a persistent mounted directory (themes or plugins) from the # immutable image source. Copy-based, never symlinked, so the volume becomes # authoritative and stays writable/upgradable by WordPress. @@ -314,6 +339,76 @@ seed_dir() { exec 9>&- } +# Print "yes" when two directory trees contain byte-identical files (same +# relative paths, same contents), "no" otherwise. Content-based on purpose: it +# lets every boot skip an unnecessary replacement without trusting mtimes +# (cp -a preserves them, but wp-admin plugin edits do not keep them honest). +same_dir() { + local l r + l="$(cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum)" + r="$(cd "$2" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum)" + [ "$l" = "$r" ] && echo yes || echo no +} + +# Converge the image-managed Cast plugin into the plugins volume. Cast is +# platform-managed like core: the volume's plugins/cast directory (absent, +# older baked copy, or user-drifted) is reconciled to the copy baked into the +# image on EVERY boot, so image upgrade and rollback both converge existing +# workspaces. This is delivery, not activation: activate_cast() below owns the +# real activation hooks. All other plugins (and wp-admin updates to them) stay +# under the volume's authority — only cast/ is ever touched here. +# +# The replaced copy is archived as plugins/.cast.bak- (dot-prefixed so +# WordPress's get_plugins() scan ignores it; one backup is kept, the previous +# one retired first) for manual operator rollback. +# +# Lock + flock mirror seed_dir so concurrent/recreated boots on a shared +# volume cannot interleave a swap; the backup-move plus copy sequence keeps +# cast/ either fully old or fully new from PHP's point of view. If the swap +# is interrupted (container dies mid-copy), the next boot sees a different +# tree and converges again — the MU guard's files-presence gate keeps the +# site healthy in any transient absence. +reconcile_cast() { + local src="$SRC/wp-content/plugins/cast" + local dst="$WP_CONTENT/plugins/cast" + + # No baked Cast in this image (or build without it): nothing to converge. + [ -d "$src" ] || return 0 + + mkdir -p "$WP_CONTENT/plugins" + exec 9>"$WP_CONTENT/plugins/.pinner-cast.lock" + flock 9 + + if [ -d "$dst" ] && [ "$(same_dir "$src" "$dst")" = yes ]; then + flock -u 9 + exec 9>&- + return 0 + fi + + rm -rf "$WP_CONTENT/plugins"/.cast.bak-[0-9]* + if [ -d "$dst" ]; then + mv "$dst" "$WP_CONTENT/plugins/.cast.bak-$(date +%s)" + fi + cp -a "$src"/. "$dst"/ + chown -R "$APP_USER:$APP_USER" "$dst" + + flock -u 9 + exec 9>&- +} + +# Copy the image-owned mu-plugins into the ephemeral wp-content. mu-plugins is +# deliberately NOT a persistent mount (it is platform-owned code, like core), +# so this runs on every boot: an image update always replaces the guard on the +# next container start, and there is no user content here to ever preserve. +seed_mu_plugins() { + local src="$SRC/wp-content/mu-plugins" + local dst="$WP_CONTENT/mu-plugins" + + mkdir -p "$dst" + cp -a "$src"/. "$dst"/ + chown -R "$APP_USER:$APP_USER" "$dst" +} + # uploads is never seeded; it only needs an existing, app-owned, writable dir. prepare_uploads() { local dst="$WP_CONTENT/uploads" @@ -340,6 +435,8 @@ main() { require_db_env seed_dir themes seed_dir plugins + reconcile_cast + seed_mu_plugins prepare_uploads fix_wp_content_ownership generate_wp_config @@ -347,8 +444,10 @@ main() { if wait_for_db; then if run_wp core is-installed >/dev/null 2>&1; then converge_admin_password + activate_cast else auto_install + activate_cast fi else echo "WARN: DB not reachable within ${WP_DB_WAIT_TRIES}s; skipping WordPress install this boot (will retry on next boot)." >&2 diff --git a/scripts/verify-pins.sh b/scripts/verify-pins.sh index 0aa0a84..c6d339c 100644 --- a/scripts/verify-pins.sh +++ b/scripts/verify-pins.sh @@ -24,7 +24,8 @@ export PHP_BASE PHP_BASE_DIGEST CADDY_VERSION \ CADDY_SHA512_AMD64 CADDY_SHA512_ARM64 \ WORDPRESS_VERSION WORDPRESS_SHA256 \ WP_CLI_VERSION WP_CLI_SHA512 \ - GO_BASE GO_BASE_DIGEST + GO_BASE GO_BASE_DIGEST \ + COMPOSER_BASE COMPOSER_BASE_DIGEST echo "== [deps] checking Caddy checksums ($CADDY_VERSION) ==" curl -fsSL -o /tmp/caddy-checksums.txt \ @@ -74,6 +75,11 @@ echo " ok WP-CLI ${WP_CLI_VERSION} sha512 matches upstream" echo "== [deps] Go builder image pin (${GO_BASE}) ==" echo " GO_BASE_DIGEST is verified by the Docker build (FROM ...@digest); pin: $GO_BASE_DIGEST" +echo "== [deps] Composer builder image pin (${COMPOSER_BASE}) ==" +# The Cast snapshot itself is intentionally NOT checksum-pinned yet (latest +# develop tree); only its Composer builder base is pinned here. +echo " COMPOSER_BASE_DIGEST is verified by the Docker build (FROM ...@digest); pin: $COMPOSER_BASE_DIGEST" + # Drift guard: versions.env is the single source of truth, but the Docker build # only sees what docker-bake.hcl injects. Verify the resolved bake plan passes # exactly the versions.env values as build ARGs (and the OCI labels that mirror @@ -112,6 +118,8 @@ if command -v docker >/dev/null 2>&1; then check_bake wordpress "WP-CLI sha512" WP_CLI_SHA512 "$WP_CLI_SHA512" check_bake wordpress "Go base" GO_BASE "$GO_BASE" check_bake wordpress "Go base digest" GO_BASE_DIGEST "$GO_BASE_DIGEST" + check_bake wordpress "Composer base" COMPOSER_BASE "$COMPOSER_BASE" + check_bake wordpress "Composer base digest" COMPOSER_BASE_DIGEST "$COMPOSER_BASE_DIGEST" # Runtime labels must mirror the same single source (they are fed from # the same bake variables, so this is cross-checking the mechanism). diff --git a/scripts/verify-wordpress.sh b/scripts/verify-wordpress.sh index 14be3c8..3323c18 100644 --- a/scripts/verify-wordpress.sh +++ b/scripts/verify-wordpress.sh @@ -87,11 +87,12 @@ admin_email="$(wp user get "$AUTH_USER" --field=user_email --allow-root)" && pass "admin '$AUTH_USER' registered with portal owner email ($admin_email)" \ || die "admin email = '$admin_email', expected '$OWNER_EMAIL'" -echo "== [verify] workspace lockdown (no FS edits, no updates, no web cron) ==" +echo "== [verify] workspace lockdown (no FS edits, install/update allowed, no web cron) ==" # wp-init.sh bakes these constants into the generated wp-config.php; assert # they are LIVE in the generated config (a mere Dockerfile string would pass -# an image-content grep but not protect the site). -for c in DISALLOW_FILE_EDIT DISALLOW_FILE_MODS AUTOMATIC_UPDATER_DISABLED DISABLE_WP_CRON; do +# an image-content grep but not protect the site). Plugin/theme editing stays +# impossible, but wp-admin plugin install/update must work (no DISALLOW_FILE_MODS). +for c in DISALLOW_FILE_EDIT AUTOMATIC_UPDATER_DISABLED DISABLE_WP_CRON; do val="$(wp config get "$c" --type=constant --allow-root)" [ "$val" = "1" ] || die "$c is not enabled in the generated wp-config.php (got: '$val')" done @@ -101,7 +102,12 @@ upd="$(wp config get WP_AUTO_UPDATE_CORE --type=constant --allow-root)" case "$upd" in 1|true|minor|major|beta) die "WP_AUTO_UPDATE_CORE enables core updates (got: '$upd')" ;; esac -pass "DISALLOW_FILE_EDIT/MODS, AUTOMATIC_UPDATER_DISABLED, WP_AUTO_UPDATE_CORE, DISABLE_WP_CRON all active" +# DISALLOW_FILE_MODS must be GONE ENTIRELY (the old image baked it): any +# definition, even 0, keeps WP's update/install machinery disabled. +if mods="$(wp config get DISALLOW_FILE_MODS --type=constant --allow-root 2>/dev/null)" && [ -n "$mods" ]; then + die "DISALLOW_FILE_MODS is still defined (got: '$mods'); wp-admin plugin installs/updates would be blocked" +fi +pass "DISALLOW_FILE_EDIT, AUTOMATIC_UPDATER_DISABLED, WP_AUTO_UPDATE_CORE, DISABLE_WP_CRON active; DISALLOW_FILE_MODS absent" echo "== [verify] supervised WP-CLI cron worker ==" # The worker is a third supervised child (PINNER_SUPERVISED_CMD) driving WP's @@ -156,6 +162,18 @@ plugins="$(docker exec "$(wp_container)" sh -c 'ls /var/www/html/wp-content/plug echo "$plugins" | grep -q akismet || die "bundled plugin akismet missing: $plugins" pass "bundled plugin akismet present" +# The platform-managed Cast plugin must be seeded with the volume and active. +echo "$plugins" | grep -qx cast || die "platform-managed cast plugin missing: $plugins" +pass "platform-managed cast plugin present on fresh volume" + +docker exec "$(wp_container)" sh -c 'test -f /var/www/html/wp-content/mu-plugins/cast-guard.php' \ + && pass "cast-guard MU plugin present" \ + || die "cast-guard MU plugin missing (force-on enforcement is dead)" + +wp plugin is-active cast --allow-root \ + || die "cast plugin is not active on a fresh volume (cast guard / activate_cast failed)" +pass "cast plugin active on fresh volume" + wp theme activate twentytwentyfive --allow-root >/dev/null pass "default theme activated" @@ -198,6 +216,14 @@ docker exec "$(wp_container)" sh -c 'test -d /var/www/html/wp-content/themes/twe && pass "default theme still present after recreation" \ || die "default theme missing after recreation" +echo "== [verify] cast survives recreation and stays (force-)active ==" +docker exec "$(wp_container)" sh -c 'test -d /var/www/html/wp-content/plugins/cast' \ + && pass "cast plugin survived recreation" \ + || die "cast plugin missing after recreation" +wp plugin is-active cast --allow-root \ + || die "cast plugin not active after recreation" +pass "cast plugin active after recreation" + echo "== [verify] deleted plugin is not resurrected (marker honoured) ==" docker exec "$(wp_container)" sh -c 'rm /var/www/html/wp-content/plugins/hello.php' $COMPOSE up -d --force-recreate "$SERVICE" @@ -206,6 +232,45 @@ docker exec "$(wp_container)" sh -c 'test ! -e /var/www/html/wp-content/plugins/ && pass "deleted bundled plugin was not resurrected" \ || die "deleted plugin came back (seeding should be one-time)" +echo "== [verify] cast guard is neutral while cast files are missing ==" +# Files are deliberately removed WITHOUT deleting the option entry first: +# the guard's read filter must keep the phantom cast/cast.php entry out of +# active_plugins (validate_active_plugins churn) while its directory is +# absent, i.e. force-on is strictly gated on files presence. WP-CLI resolves +# a plugin operand by scanning the plugins directory, so the option is +# deactivated BEFORE the rm below — after deletion, "wp plugin ..." commands +# for cast fail without ever rewriting the persisted option. +wp plugin deactivate cast --allow-root >/dev/null +docker exec "$(wp_container)" sh -c 'rm -rf /var/www/html/wp-content/plugins/cast' +active_json="$(wp option get active_plugins --format=json --allow-root)" +echo "$active_json" | grep -q 'cast/cast.php' \ + && die "cast guard re-added a phantom active-plugins entry while cast files are missing: $active_json" \ + || pass "cast guard neutral while cast files are missing (option read: $active_json)" + +echo "== [verify] boot reconcile restores cast from the image bake ==" +# Recreate: reconcile_cast() converges the volume's cast dir from the image +# source and activate_cast() performs the real activation transition. +$COMPOSE up -d --force-recreate "$SERVICE" +wait_app +docker exec "$(wp_container)" sh -c 'test -f /var/www/html/wp-content/plugins/cast/cast.php' \ + && pass "cast restored on existing volume by boot reconcile" \ + || die "cast not restored by boot reconcile" +wp plugin is-active cast --allow-root \ + || die "cast not re-activated after reconcile restore" + +echo "== [verify] boot reconcile converges a drifted cast copy ==" +# A copy that diverged from the image bake (user edit, partial update) is +# replaced; the pre-swap copy is archived as plugins/.cast.bak- +# (dot-prefixed so WordPress's get_plugins() scan never sees it). +docker exec "$(wp_container)" sh -c \ + 'printf "\n// tampered\n" >> /var/www/html/wp-content/plugins/cast/cast.php' +$COMPOSE up -d --force-recreate "$SERVICE" +wait_app +docker exec "$(wp_container)" sh -c \ + 'grep -q "// tampered" /var/www/html/wp-content/plugins/cast/cast.php' \ + && die "boot reconcile did not converge a drifted cast copy to the image bake" \ + || pass "drifted cast copy converged back to the image bake" + echo "== [verify] per-boot admin password rotation ==" # Bump WORKSPACE_AUTH_PASSWORD (via compose interpolation) and recreate: the # image must converge the WordPress admin password on the next boot. The new