Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/metatavu/metatavu-wordpress-iac/archive/refs/heads/develop.zipWordPress must-use plugin that lets OpenTofu, running in a project's CI, read and change a site's whitelisted options, plugins and themes through metatavu-wpengine-opentofu, and nothing else. Every request is authenticated with a GitHub Actions OIDC token.
Released as a zip for the project's bootstrap/ to install (see Releases).
Layout
wp-content/mu-plugins/
├── metatavu-wordpress-iac.php the loader
└── metatavu-wordpress-iac/
├── plugin.php
├── config.php written only by the project's bootstrap/
├── src/
└── vendor/
config.php returns the install's trust settings and whitelist:
<?php
return array(
'install' => 'acmedev',
'audience' => 'https://acmedev.wpengine.com',
'trust' => array(
'repository_id' => 123456789,
'repository_owner_id' => 987654,
'read' => array( 'develop-plan' ),
'write' => array( 'develop' ),
),
'whitelist' => array(
'options' => array(
'blogdescription' => array(),
'acme_api' => array( 'sensitive' => true ),
),
'plugins' => array( 'wordpress-seo' ),
'themes' => array( 'acme' ),
),
);
Without a valid config.php, every request gets 503. define( 'METATAVU_IAC_DISABLED', true ); in wp-config.php switches the API off.
API
REST namespace metatavu-iac/v1, HTTPS only, every response Cache-Control: no-store. Every request carries Authorization: Bearer <GitHub OIDC token>. A token is accepted when its RS256 signature verifies against GitHub's keys, and its issuer, audience (the config's audience), repository_id, repository_owner_id, environment and lifetime (issued at most five minutes ago, a minute of clock skew) are right. Tokens from a read environment can only read; write tokens are accepted once.
| Method and path | Scope | Does |
|---|---|---|
GET /state |
read | The whitelisted options (a sensitive one only as the SHA-256 of its canonical JSON), plugins and themes with their versions and whether a plugin is active, the active theme, and the WordPress and PHP versions |
PUT /options/{name} |
write | Sets a whitelisted option to {"value": <JSON>} |
PUT /plugins/{slug}, PUT /themes/{slug} |
write | Installs or updates a whitelisted package, and sets whether a plugin is active. From wordpress.org: {"source": "wordpress.org", "active": true}, installed unless it is (WP Engine keeps it up to date). A release: see below |
DELETE /plugins/{slug}, DELETE /themes/{slug} |
write | Deactivates a plugin and deletes the package. The active theme and its parent can't be deleted (409) |
PUT /active-theme |
write | Activates a whitelisted, installed theme: {"slug": "<theme>"} |
GET /changes?limit=100 |
read | Changes to managed options made outside the API, newest first (at most 1000) |
A release is uploaded as multipart: the zip as package, and the fields source=release, version, sha256 and, for a plugin, active. PHP parses multipart bodies only for POST, so it's sent as POST with X-HTTP-Method-Override: PUT. Before anything is unpacked, the plugin checks the zip's SHA-256, that it holds only the slug's directory, and that its header declares version. WordPress's upgraders install it, keeping the previous version in a temporary backup and restoring it if the install fails. A zip larger than the site's upload_max_filesize (50 MB on WP Engine) gets 413. The plugin never manages itself (metatavu-wordpress-iac), and wordpress.org is the only place it downloads from.
siteurl, home, default_role, users_can_register, active_plugins, template, stylesheet, admin_email and the user roles option are refused even when whitelisted.
Values are stored as WordPress stores options: a JSON object becomes a PHP array with string keys, so an empty object reads back as [], and WordPress stores a scalar option as a string, so declare scalars as strings. A sensitive option's hash is the SHA-256 of its value as JSON with object keys sorted at every level and no escaped slashes or Unicode; the provider hashes a declared value after the same normalisation.
A change to a managed option made outside the API (wp-admin, WP-CLI, another plugin, cron) is logged with the WordPress user, where it came from (cli, cron, rest, ajax, admin:<path>, web:<path>), and the old and new values, or only that it changed for a sensitive option. The next plan shows the drift; GET /changes explains it.
Every authorized write is logged with its outcome (ok or the error code, because a failed write can leave a partial change) and the token's repository, run, commit, actor, environment and jti, and kept for 365 days. Activating and deactivating a plugin run its own hooks, as in wp-admin.
Development
Nothing PHP is needed locally: scripts/dev.sh runs the tools in a PHP 8.4 image (dev/Dockerfile).
scripts/dev.sh composer install
scripts/dev.sh composer lint # phpcs: WordPress coding standards, PHPCompatibility 8.4+
scripts/dev.sh composer test # PHPUnit with Brain Monkey
End to end, in a local WordPress (wp-env) with a test token issuer, which the plugin accepts only when the environment type is local:
npx @wordpress/env start
tests/e2e/run.sh # WP_ENV_PORT when not 8888; needs curl, jq and zip, and network for wordpress.org
tests/e2e/issue-token.php writes the test config.php and issues tokens, for trying the API by hand. CI runs the end-to-end tests against the release build: scripts/build.sh, then cp tests/e2e/wp-env.build.json .wp-env.override.json before starting wp-env, and E2E_PLUGIN_DIR=build/plugin/metatavu-wordpress-iac tests/e2e/run.sh.
Installing
A project's bootstrap/ installs the plugin on each install from a laptop, over WP Engine's SSH gateway with the person's own SSH key; CI never does. scripts/install-mu-plugin.sh, published with each release:
- downloads the pinned release and checks its SHA-256 before anything is uploaded;
- uploads the zip and
config.phpwith rsync to a staging directory under_wpeprivate(kept between sessions, never served; the gateway has no SFTP, and/tmpisn't kept); - on the install, unpacks it, checks
config.phpwithphp -l, and swaps the plugin intomu-plugins: the directory, then the loader; - checks that WordPress loads the plugin, and if it doesn't, puts the previous version back.
Between the swap and that check, a few seconds, a broken release is live. The config comes in IAC_CONFIG_PHP, so nothing is written to disk on the laptop. Keep a copy of the script in the project's scripts/, from the release it installs, and declare a terraform_data per install, which runs it again whenever the version, the checksum or the config changes:
locals {
iac_plugin = { version = "0.2.0", sha256 = "<the zip's SHA-256 from the release>" }
# config.php returns the trust settings and whitelist; JSON in a nowdoc keeps the PHP trivial.
iac_config = {
for env, install in wpengine_install.this : env => <<-PHP
<?php
return json_decode( <<<'JSON'
${jsonencode({
install = install.name
audience = "https://${install.name}.wpengine.com"
trust = { repository_id = data.github_repository.infra.repo_id, repository_owner_id = 987654, read = ["${env}-plan"], write = [env] }
whitelist = { options = {}, plugins = [], themes = [] }
})}
JSON, true );
PHP
}
}
resource "terraform_data" "iac_plugin" {
for_each = wpengine_install.this
triggers_replace = [local.iac_plugin.version, local.iac_plugin.sha256, sha256(local.iac_config[each.key])]
provisioner "local-exec" {
command = "${path.root}/../scripts/install-mu-plugin.sh --ssh ${each.value.name}@${each.value.name}.ssh.wpengine.net --install ${each.value.name} --version ${local.iac_plugin.version} --sha256 ${local.iac_plugin.sha256}"
environment = { IAC_CONFIG_PHP = local.iac_config[each.key] }
}
}
--zip <file> installs a local zip instead of the release, for testing.
Releases
scripts/build.sh builds dist/metatavu-wordpress-iac-<version>.zip and its .sha256, and copies install-mu-plugin.sh with its .sha256. The zip holds the loader and metatavu-wordpress-iac/, with firebase/php-jwt renamed into Metatavu\WordPressIaC\Vendor by PHP-Scoper (pinned and checksummed), a class-map autoloader, and no development files; the build fails if any of that doesn't hold. A v* tag on main whose version matches the plugin header publishes the zip and its checksum as a GitHub release. The zip has no config.php: the project's bootstrap/ writes it.