WP Manifestindependent plugin directory
manifest / developer / metatavu-wordpress-iac

Metatavu WordPress IaC

WordPress must-use plugin that lets OpenTofu manage options and plugins, authenticated with GitHub OIDC

★ 0stars

Install

Download source zip No release zip yet

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.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

WordPress 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:

  1. downloads the pinned release and checks its SHA-256 before anything is uploaded;
  2. uploads the zip and config.php with rsync to a staging directory under _wpeprivate (kept between sessions, never served; the gateway has no SFTP, and /tmp isn't kept);
  3. on the install, unpacks it, checks config.php with php -l, and swaps the plugin into mu-plugins: the directory, then the loader;
  4. 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.