Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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}"],
Expand Down
49 changes: 49 additions & 0 deletions images/wordpress/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
##
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 46 additions & 13 deletions images/wordpress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` |
Expand Down Expand Up @@ -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`)
Expand All @@ -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.
Expand Down Expand Up @@ -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-<epoch>` (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 |
Expand Down
101 changes: 101 additions & 0 deletions images/wordpress/mu-plugins/cast-guard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/**
* Plugin Name: Cast Guard
* Description: Keeps the platform-managed Cast plugin active. Cast is workspace
* infrastructure provisioned by the image (like wp-config.php, not
* user content), so deactivation through the admin UI or direct
* option writes is enforced away on every request.
* Version: 0.1.0
* Author: LumeWeb
* License: GPL-3.0-or-later
*/

declare(strict_types=1);

if (!defined('ABSPATH')) {
exit;
}

const CAST_MAIN = 'cast/cast.php';

/*
* WP 7.1 load-order compatibility shim: wp-settings.php loads (must-use and)
* regular plugins BEFORE wp-includes/pluggable.php, and Cast calls wp_salt()
* during its boot (SignaturePepper). Early-require pluggable for everything
* after this point. This is safe: every pluggable function (wp_salt included)
* is wrapped in function_exists(), so core's later plain `require` of the
* file re-executes the same guarded definitions and redefines nothing.
* Same-root fix belongs in Cast (resolve the pepper lazily, after pluggable
* is loaded); this shim is removed once Cast is boot-safe on its own.
*/
if (!function_exists('wp_salt') && defined('ABSPATH') && defined('WPINC')) {
require_once ABSPATH . WPINC . '/pluggable.php';
}

/*
* Force-on only applies while the managed plugin files actually exist. A
* workspace whose cast/ directory is transiently absent (interrupted boot
* reconcile, or files deleted out-of-band by an operator) must NOT get a
* phantom active-plugins entry churned in by this filter; wp-init.sh's
* boot-time reconcile_cast() restores the directory from the image bake on
* the next start, after which this filter force-activates again.
*/
$cast_files_present = static function (): bool {
return file_exists(constant('WP_PLUGIN_DIR') . '/' . CAST_MAIN);
};

/*
* Re-add Cast on EVERY read of the active_plugins option, regardless of how it
* was last written (admin Deactivate, WP-CLI, REST). A read-time filter means
* Cast is "active" again before the next request boots, so no separate boot run
* is needed for enforcement — wp-init.sh's `wp plugin activate cast` only
* performs the REAL one-time activation transition (schema install, rewrite
* flush); it is not the enforcement mechanism.
*
* Not hooked: pre_option_active_plugins would also defeat legitimate core
* logic that manages the option; return-value filtering after the read keeps
* every other active plugin exactly as written.
*/
add_filter(
'option_active_plugins',
static function (array $plugins) use ($cast_files_present): array {
if (in_array(CAST_MAIN, $plugins, true) || !$cast_files_present()) {
return $plugins;
}

return array_merge($plugins, [CAST_MAIN]);
}
);
Comment thread
kody-ai[bot] marked this conversation as resolved.

/*
* Network-active list force-on. This only ever fires in multisite contexts
* (single-site workspaces never read active_sitewide_plugins), where Cast must
* be enabled network-wide to be active. Cast registers no network-specific
* lifecycle today, so a plain entry is sufficient. Same existence gate as the
* regular active-plugins filter above.
*/
add_filter(
'site_option_active_sitewide_plugins',
static function (array $sitewide) use ($cast_files_present): array {
if (!array_key_exists(CAST_MAIN, $sitewide) && $cast_files_present()) {
$sitewide[CAST_MAIN] = true;
}

return $sitewide;
}
);

// Hide the Deactivate action so the admin UI matches reality: the toggle would
// appear to work (option write succeeds) but is overridden at the next read,
// which is worse than not offering it. This covers the site-admin Plugins
// screen; the network-admin equivalent is deliberately not hooked because
// network admin is not a supported surface yet.
add_filter(
'plugin_action_links_' . CAST_MAIN,
static function (array $actions): array {
unset($actions['deactivate']);

return $actions;
}
);
7 changes: 7 additions & 0 deletions images/wordpress/versions.env
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ WP_CLI_SHA512=be928f6b8ca1e8dfb9d2f4b75a13aa4aee0896f8a9a0a1c45cd5d2c98605e6172e
# the workspace-init module's go.mod requires.
GO_BASE=golang:1.26.1-bookworm
GO_BASE_DIGEST=sha256:ab3d6955bbc813a0f3fdf220c1d817dd89c0b3f283777db8ece4a32fe7858edd

# Composer builder image used to vendor the platform-managed Cast plugin from
# the latest GitHub `develop` tree snapshot (pinned by exact digest; multi-arch
# manifest-list digest). This is a BASE pin only: the Cast snapshot itself is
# deliberately not checksum-pinned yet (that evolves with the release artifact).
COMPOSER_BASE=composer:2.10.3
COMPOSER_BASE_DIGEST=sha256:a5f59b9fd2faf31218632be4809dc6491761085e8064c31dc3b84378c48c248b
Loading
Loading