fix(web-app-wordpress): run every addon through its declaration

The deploy died in the drill's second pass on `wp plugin install activitypub
--activate`: the first pass had installed it, the DR restore put the tree back,
and wp-cli refuses to unpack onto an occupied directory. `--force` would have
made that green by overwriting whatever the restore produced - on a branch about
NFS-backed volumes, the worst possible place to paper over a restore. The probe
keeps it red instead: `wp plugin is-installed` answers from `get_plugins()`
alone (verified in the pinned wp-cli phar: `Fetchers\Plugin::get()` iterates
`get_plugins()` and nothing else), so a tree WordPress cannot parse still
reaches the install and still fails on folder_exists.

Activation moves into its own step. Chained to `install --activate`, wp-cli sets
chained_command and skips the result check, so an activation failure returns 0
today.

The same install path now serves both call sites; the addon hook carried its own
copy, which is why one defect needed fixing twice.

mu-plugins follow the declaration the way discourse already does. The loop reads
`mechanism: mu_plugin` from meta/addons/ instead of spelling two filenames, which
is how `infinito-http-ca-trust` shipped undeclared while `infinito-oidc-rbac-mapper`
carried a `required: true` that nothing evaluated. Both install paths honour that
field now - a required addon aborts the play, an optional one warns and is
skipped - which is the first place in the repo to read it.

Three guards, because the loop replaced two guaranteed literals: an assert that
every required addon is claimed by some install path, a lint pairing each
vendored addon with its shipped file in both directions, and the spec the addon
contract demands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-01 02:13:25 +02:00
1 parent 777d46e276
commit 2f7cd543ad
12 files changed
+231 -61

No files matched your search

+6 -6
View File
@@ -119,7 +119,7 @@ docker run --rm -it \
The goal of this deployment is to provide a production-ready, scalable WordPress instance with multisite capabilities and enhanced performance. By automating the custom image build and configuration processes via Docker Compose and Ansible, it minimizes manual intervention, reduces errors, and allows you to concentrate on building great content.
## Multisite (requirement 005)
## Multisite
WordPress Multisite is opt-in. Set `services.wordpress.multisite.enabled: true` in the inventory to convert the deployed instance into a sub-domain Multisite network. Every entry in `domains.canonical` becomes a site in the network; the first entry is the network primary, subsequent entries are child sites.
@@ -132,9 +132,8 @@ Operator-facing instructions for assigning these groups live in [Administration
## Addons
Plugins and the OIDC→RBAC mu-plugin are declared in
[`meta/addons/`](./meta/addons/) under the unified addon contract
(requirement 026). The OIDC and WP-Discourse runtime config lives in each addon's `config:` block.
Every plugin and mu-plugin is declared in [`meta/addons/`](./meta/addons/) under
the unified addon contract. The OIDC and WP-Discourse runtime config lives in each addon's `config:` block. Both install paths read those declarations: a `required` addon that fails to install stops the deploy, an optional one warns and is skipped.
| Addon | Mechanism | Default state | Bridges |
|-------|-----------|---------------|---------|
@@ -142,17 +141,18 @@ Plugins and the OIDC→RBAC mu-plugin are declared in
| `wp-discourse` | `plugin` | enabled with the `discourse` service | `discourse` → `web-app-discourse` |
| `activitypub` | `plugin` | always enabled (Fediverse federation) | none |
| `infinito-oidc-rbac-mapper` | `mu_plugin` | `required` (always installed, vendored) | `sso` → `web-app-keycloak` |
| `infinito-http-ca-trust` | `mu_plugin` | `required` (always installed, vendored) | none |
The OIDC login + RBAC paths are covered by `test-admin-oidc-login.js` /
`test-rbac-roles.js`, the Discourse round-trip by `test-discourse-roundtrip.js`.
## Playwright service dependencies
The Playwright suite in [files/playwright/playwright.spec.js](files/playwright/playwright.spec.js) gates its scenarios on the following shared services (requirement 006). Scenarios that depend on a service report as `skipped` when the corresponding `<SERVICE>_SERVICE_ENABLED=false` in the staged `.env`:
The Playwright suite in [files/playwright/playwright.spec.js](files/playwright/playwright.spec.js) gates its scenarios on the following shared services. Scenarios that depend on a service report as `skipped` when the corresponding `<SERVICE>_SERVICE_ENABLED=false` in the staged `.env`:
- `oidc`: baseline admin OIDC round-trip plus the three RBAC scenarios (subscriber/editor/administrator).
- `ldap`: the three RBAC scenarios additionally depend on LDAP group sync; disabling LDAP skips them alongside OIDC.
- `discourse`: the WP->Discourse post round-trip scenario (requirement 007). Disabling Discourse skips it; front-page reachability and CSP baselines stay active.
- `discourse`: the WP->Discourse post round-trip scenario. Disabling Discourse skips it; front-page reachability and CSP baselines stay active.
The front-page CSP + canonical-domain baseline is ungated and always runs.
@@ -16,20 +16,24 @@ See the [WordPress Must-Use Plugins handbook](https://developer.wordpress.org/ad
Put a file in this directory only when the behavior it implements is part of the security or integration contract of this role and MUST remain in effect for every request. Everyday feature plugins SHOULD stay in normal `wp-content/plugins/` and be managed through [05_enable_plugin.yml](../../tasks/05_enable_plugin.yml) so operators can disable them per site if needed.
Each file in this directory SHOULD declare its purpose, hooks, and source requirement at the top of the file so readers can understand why it cannot be switched off.
Each file in this directory SHOULD declare its purpose and the hooks it registers at the top of the file so readers can understand why it cannot be switched off.
## OIDC -> RBAC mapping (infinito-oidc-rbac-mapper.php) 🎫
[infinito-oidc-rbac-mapper.php](infinito-oidc-rbac-mapper.php) is the only file shipped here today. It implements the OIDC -> WordPress role contract from requirements 004 and 005:
[infinito-oidc-rbac-mapper.php](infinito-oidc-rbac-mapper.php) implements the OIDC -> WordPress role contract:
- **Single-Site path**: the claim MUST contain `/roles/web-app-wordpress/<role>` entries. The highest-privilege role across all matches wins (`administrator > editor > author > contributor > subscriber`). When no entry matches, the user's role is set to `subscriber` as a deterministic fallback.
- **Multisite path** (auto-detected via `is_multisite()`): per-site roles come from `/roles/web-app-wordpress/<canonical-domain>/<role>` entries; the super-admin capability comes from `/roles/web-app-wordpress/network-administrator`. The mapper adds the user to any site they have a role for (`add_user_to_blog`) and removes them from sites it previously added them to (`remove_user_from_blog`) when a role for that site disappears from the claim. A user-meta marker (`_infinito_oidc_added_blog_ids`) records which blog memberships the mapper owns, so memberships added through `wp-admin` or the REST API outside the OIDC flow are never touched.
For the broader RBAC contract (LDAP layout, `rbac.tenancy` schema, the `rbac_group_path` lookup plugin), see [rbac.md](../../../../docs/contributing/design/iam/rbac.md).
## HTTP CA trust (infinito-http-ca-trust.php) 🔐
[infinito-http-ca-trust.php](infinito-http-ca-trust.php) points WordPress's HTTP API at the deployment's CA bundle so outbound calls to internal HTTPS endpoints (OIDC discovery, REST loopback) verify instead of failing.
## Deployment 🚚
Files in this directory are copied into the running container by [04_mu_plugins.yml](../../tasks/04_mu_plugins.yml). The task runs on every deploy, so dropping a new file here is enough to have it picked up on the next playbook run.
Every file here is declared as an addon under [meta/addons/](../../meta/addons/) with `mechanism: mu_plugin` and `source: vendored`, and the addon id is the file stem. [04_mu_plugins.yml](../../tasks/04_mu_plugins.yml) loops over those declarations and copies `<addon_id>.php` into the container on every deploy; a new file therefore needs its `meta/addons/<addon_id>.yml` entry and a Playwright spec before it is installed.
## Credits 🙏
@@ -0,0 +1,44 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon infinito-http-ca-trust: the CA-trust mu-plugin is loaded by WordPress and outbound HTTPS calls resolve", async ({
browser,
}) => {
skipUnlessAddonEnabled("infinito-http-ca-trust");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await page.goto(
`${shared.env.wpBaseUrl}/wp-admin/plugins.php?plugin_status=mustuse`,
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
await expect(
page.locator("#the-list"),
"the Must-Use plugins screen must list infinito-http-ca-trust.php — a mu-plugin that is absent from this screen was never copied into wp-content/mu-plugins"
).toContainText("infinito-http-ca-trust.php", { timeout: 30_000 });
const restResponse = await page.request.get(
`${shared.env.wpBaseUrl}/wp-json/`,
{ failOnStatusCode: false }
);
expect(
restResponse.status(),
"the REST root must answer over HTTPS: WordPress reaching its own TLS endpoint is what this mu-plugin's CA bundle enables"
).toBe(200);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,4 @@
---
required: true
mechanism: mu_plugin
source: vendored
@@ -1,46 +1,15 @@
---
# Install mu-plugins into the running WordPress container so they are
# always active (mu-plugins cannot be deactivated from the admin UI).
# Currently installs the OIDC→RBAC role mapper.
- name: "✅ Ensure mu-plugins directory exists inside the container"
shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
mkdir -p {{ WORDPRESS_DOCKER_HTML_PATH }}/wp-content/mu-plugins
mkdir -p {{ WORDPRESS_MU_PLUGINS_DIR }}
changed_when: false
- name: "📋 Copy infinito-oidc-rbac-mapper.php into the container"
shell: >
container exec -i {{ WORDPRESS_CONTAINER_ADDRESS }}
tee {{ WORDPRESS_DOCKER_HTML_PATH }}/wp-content/mu-plugins/infinito-oidc-rbac-mapper.php
> /dev/null
args:
stdin: "{{ lookup('file', [role_path, 'files/mu-plugins/infinito-oidc-rbac-mapper.php'] | path_join) }}"
register: cp_mu_plugin
changed_when: cp_mu_plugin.rc == 0
failed_when: cp_mu_plugin.rc != 0
- name: "✅ Ensure WordPress user owns the mu-plugin file"
shell: >
container exec {{ WORDPRESS_CONTAINER_ADDRESS }}
chown {{ WORDPRESS_USER }}:{{ WORDPRESS_USER }}
{{ WORDPRESS_DOCKER_HTML_PATH }}/wp-content/mu-plugins/infinito-oidc-rbac-mapper.php
changed_when: false
- name: "📋 Copy {{ WORDPRESS_CA_TRUST_MU_PLUGIN_FILE }} into the container"
shell: >
container exec -i {{ WORDPRESS_CONTAINER_ADDRESS }}
tee {{ WORDPRESS_CA_TRUST_MU_PLUGIN_PATH }}
> /dev/null
args:
stdin: "{{ lookup('file', [role_path, 'files/mu-plugins', WORDPRESS_CA_TRUST_MU_PLUGIN_FILE] | path_join) }}"
register: cp_ca_trust_mu_plugin
changed_when: cp_ca_trust_mu_plugin.rc == 0
failed_when: cp_ca_trust_mu_plugin.rc != 0
- name: "✅ Ensure WordPress user owns the CA-trust mu-plugin file"
shell: >
container exec {{ WORDPRESS_CONTAINER_ADDRESS }}
chown {{ WORDPRESS_USER }}:{{ WORDPRESS_USER }}
{{ WORDPRESS_CA_TRUST_MU_PLUGIN_PATH }}
changed_when: false
- name: "🔁 Iterate through declared WordPress mu-plugins"
include_tasks: utils/install_mu_plugin.yml
loop: "{{ WORDPRESS_MU_PLUGINS }}"
loop_control:
loop_var: wordpress_mu_plugin
label: "{{ wordpress_mu_plugin.key }}"
vars:
mu_plugin_name: "{{ wordpress_mu_plugin.key }}"
@@ -10,8 +10,5 @@
include_tasks: "{{ plugin_task_path }}"
when: plugin_task_file.stat.exists
- name: "📦 Install and activate WordPress plugin via WP CLI"
ansible.builtin.shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
wp plugin install {{ plugin_name }} --activate
--path={{ WORDPRESS_DOCKER_HTML_PATH }}
- name: "📦 Ensure WordPress plugin is installed and active"
ansible.builtin.include_tasks: utils/install_plugin.yml
@@ -19,7 +19,17 @@
(lookup('config', application_id, 'services.wordpress.multisite.enabled')
| default(false) | bool)
- name: "📦 Install mu-plugins (OIDC→RBAC role mapper)"
- name: "🛑 Assert every required addon is claimed by an install path"
ansible.builtin.assert:
that:
- WORDPRESS_ADDONS_REQUIRED | difference(WORDPRESS_ADDONS_INSTALLED) | length == 0
fail_msg: >-
Required addon(s)
{{ WORDPRESS_ADDONS_REQUIRED | difference(WORDPRESS_ADDONS_INSTALLED) | join(', ') }}
are declared in meta/addons/ but no install loop selects them; their
`mechanism` must be `plugin` or `mu_plugin` to be installed at all.
- name: "📦 Install declared mu-plugins"
include_tasks: 04_mu_plugins.yml
- name: "🔁 Iterate through WordPress plugins"
@@ -21,10 +21,9 @@
changed_when: false
- name: "📦 Install WP Discourse plugin"
ansible.builtin.shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
wp plugin install wp-discourse --activate
--path={{ WORDPRESS_DOCKER_HTML_PATH }}
ansible.builtin.include_tasks: ../utils/install_plugin.yml
vars:
plugin_name: wp-discourse
when: IS_STACK_HOST | bool
- name: "🔧 Configure WP Discourse settings"
@@ -0,0 +1,32 @@
---
- name: "📦 Normalise the install contract for '{{ mu_plugin_name }}'"
ansible.builtin.set_fact:
wp_mu_plugin_required: "{{ lookup('config', application_id, 'addons.' ~ mu_plugin_name ~ '.required', false) | bool }}"
changed_when: false
- name: "📋 Copy mu-plugin '{{ mu_plugin_name }}' into the container"
ansible.builtin.shell: >
container exec -i {{ WORDPRESS_CONTAINER_ADDRESS }}
tee {{ [WORDPRESS_MU_PLUGINS_DIR, mu_plugin_name ~ '.php'] | path_join }}
> /dev/null
args:
stdin: "{{ lookup('file', [role_path, 'files/mu-plugins', mu_plugin_name ~ '.php'] | path_join) }}"
register: wp_mu_plugin_copy
changed_when: wp_mu_plugin_copy.rc == 0
failed_when: wp_mu_plugin_copy.rc != 0 and wp_mu_plugin_required | bool
- name: "🟡 Skip optional mu-plugin '{{ mu_plugin_name }}' after a failed copy"
ansible.builtin.debug:
msg: >-
::warning title=addon install failed::{{ application_id }} addon
'{{ mu_plugin_name }}' was not copied into the container
(rc={{ wp_mu_plugin_copy.rc }}); deploy continues because it is not required
when: wp_mu_plugin_copy.rc != 0
- name: "✅ Ensure WordPress user owns mu-plugin '{{ mu_plugin_name }}'"
ansible.builtin.shell: >
container exec {{ WORDPRESS_CONTAINER_ADDRESS }}
chown {{ WORDPRESS_USER }}:{{ WORDPRESS_USER }}
{{ [WORDPRESS_MU_PLUGINS_DIR, mu_plugin_name ~ '.php'] | path_join }}
changed_when: false
when: wp_mu_plugin_copy.rc == 0
@@ -0,0 +1,42 @@
---
- name: "📦 Normalise the install contract for '{{ plugin_name }}'"
ansible.builtin.set_fact:
wp_plugin_required: "{{ lookup('config', application_id, 'addons.' ~ plugin_name ~ '.required', false) | bool }}"
changed_when: false
- name: "🔎 Check if WordPress plugin '{{ plugin_name }}' is installed"
ansible.builtin.shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
wp plugin is-installed {{ plugin_name }}
--path={{ WORDPRESS_DOCKER_HTML_PATH }}
register: wp_plugin_present
changed_when: false
failed_when: wp_plugin_present.rc not in [0, 1]
- name: "📦 Install WordPress plugin '{{ plugin_name }}' via WP CLI"
ansible.builtin.shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
wp plugin install {{ plugin_name }}
--path={{ WORDPRESS_DOCKER_HTML_PATH }}
register: wp_plugin_install
when: wp_plugin_present.rc != 0
failed_when: wp_plugin_install.rc != 0 and wp_plugin_required | bool
- name: "🟡 Skip optional WordPress plugin '{{ plugin_name }}' after a failed install"
ansible.builtin.debug:
msg: >-
::warning title=addon install failed::{{ application_id }} addon
'{{ plugin_name }}' did not install (rc={{ wp_plugin_install.rc }});
activation skipped, deploy continues because the addon is not required
when: (wp_plugin_install.rc | default(0)) != 0
- name: "✅ Activate WordPress plugin '{{ plugin_name }}' via WP CLI"
ansible.builtin.shell: >
container exec -u {{ WORDPRESS_USER }} {{ WORDPRESS_CONTAINER_ADDRESS }}
wp plugin activate {{ plugin_name }}
--path={{ WORDPRESS_DOCKER_HTML_PATH }}
register: wp_plugin_activate
when: (wp_plugin_install.rc | default(0)) == 0
changed_when: >
wp_plugin_activate.rc == 0
and ('already' not in (wp_plugin_activate.stderr | lower))
+4 -2
View File
@@ -14,6 +14,9 @@ WORDPRESS_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, ent
WORDPRESS_DISCOURSE_CONTAINER_ADDRESS: "{{ lookup('config', 'web-app-discourse', 'services.discourse.name') }}"
WORDPRESS_DOMAINS: "{{ lookup('config', application_id, 'domains.canonical') }}"
WORDPRESS_PLUGINS: "{{ lookup('config', application_id, 'addons') | dict2items | selectattr('value.mechanism', 'equalto', 'plugin') | list }}"
WORDPRESS_MU_PLUGINS: "{{ lookup('config', application_id, 'addons') | dict2items | selectattr('value.mechanism', 'equalto', 'mu_plugin') | list }}"
WORDPRESS_ADDONS_INSTALLED: "{{ (WORDPRESS_PLUGINS + WORDPRESS_MU_PLUGINS) | map(attribute='key') | list }}"
WORDPRESS_ADDONS_REQUIRED: "{{ lookup('config', application_id, 'addons') | dict2items | selectattr('value.required', 'defined') | selectattr('value.required') | map(attribute='key') | list }}"
WORDPRESS_USER: "www-data"
WORDPRESS_MSMTP_SRC: "{{ lookup('path_absolute', 'roles/sys-svc-mail-msmtp/templates/msmtprc.conf.j2') }}"
@@ -24,8 +27,7 @@ WORDPRESS_CONFIG_PATH: "{{ [WORDPRESS_DOCKER_HTML_PATH, WORDPRESS_CONFIG_FILE] |
WORDPRESS_CONFIG_UPLD_REL: "config/upload.ini"
WORDPRESS_CONFIG_UPLD_ABS: "{{ [lookup('container', application_id, 'directories.instance'), WORDPRESS_CONFIG_UPLD_REL ] | path_join }}"
WORDPRESS_CA_TRUST_MU_PLUGIN_FILE: "infinito-http-ca-trust.php"
WORDPRESS_CA_TRUST_MU_PLUGIN_PATH: "{{ [WORDPRESS_DOCKER_HTML_PATH, 'wp-content/mu-plugins', WORDPRESS_CA_TRUST_MU_PLUGIN_FILE] | path_join }}"
WORDPRESS_MU_PLUGINS_DIR: "{{ [WORDPRESS_DOCKER_HTML_PATH, 'wp-content/mu-plugins'] | path_join }}"
WORDPRESS_ADMIN_EMAIL: "{{ lookup('config', application_id, 'users.administrator.email') }}"
WORDPRESS_ADMIN_USER: "{{ lookup('config', application_id, 'users.administrator.username') }}"
@@ -0,0 +1,67 @@
"""A vendored addon and its shipped file must find each other.
``source: vendored`` means the payload is committed under the role. The
install path resolves it by convention - ``files/mu-plugins/<addon_id>.php``
for a WordPress mu-plugin - so a rename on either side silently breaks the
deploy: the copy step fails on a path nobody typed, and a file with no
declaration is never installed at all. That second direction is how
``infinito-http-ca-trust`` shipped for months while the loop that installs
mu-plugins never saw it.
"""
from __future__ import annotations
import unittest
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_DIR_META_ADDONS
from . import PROJECT_ROOT
ROLES_DIR = PROJECT_ROOT / "roles"
VENDORED_DIRS = {"mu_plugin": ("files/mu-plugins", ".php")}
def _vendored_addons():
for role in sorted(ROLES_DIR.iterdir()):
addons_dir = role / ROLE_DIR_META_ADDONS
if not addons_dir.is_dir():
continue
for spec_file in sorted(addons_dir.glob("*.yml")):
spec = load_yaml_any(str(spec_file), default_if_missing={}) or {}
layout = VENDORED_DIRS.get(spec.get("mechanism"))
if layout and spec.get("source") == "vendored":
yield role, spec_file.stem, layout
class TestAddonsVendoredFiles(unittest.TestCase):
def test_every_vendored_addon_ships_its_file(self) -> None:
missing = [
f"{role.name}: addon '{addon_id}' declares source: vendored "
f"but {subdir}/{addon_id}{suffix} does not exist"
for role, addon_id, (subdir, suffix) in _vendored_addons()
if not (role / subdir / f"{addon_id}{suffix}").is_file()
]
if missing:
self.fail("\n".join(missing))
def test_every_shipped_file_is_declared(self) -> None:
declared = {
(role.name, subdir, addon_id)
for role, addon_id, (subdir, _) in _vendored_addons()
}
orphans = []
for role in sorted(ROLES_DIR.iterdir()):
for subdir, suffix in VENDORED_DIRS.values():
for shipped in sorted((role / subdir).glob(f"*{suffix}")):
if (role.name, subdir, shipped.stem) not in declared:
orphans.append(
f"{role.name}: {subdir}/{shipped.name} has no "
f"meta/addons/{shipped.stem}.yml, so nothing installs it"
)
if orphans:
self.fail("\n".join(orphans))
if __name__ == "__main__":
unittest.main()