From e61665e095906dc4cd85ecfedf958f108234619a Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:35:21 -0600 Subject: [PATCH 1/5] init wordpress WordPress 1.0.0: Apache + mod_php on a `shared` (read-write-many) volumeset, with a bundled MariaDB 11 via the `mariadb` 1.4.1 subchart. The admin account is created from a required prerequisite dictionary secret BEFORE Apache binds, so /wp-admin/install.php is never reachable and public access can default on. Build-stage probes (all five spec [UNPROVEN] items settled): - A `standard` workload MAY mount a `shared` volumeset (reached ready: true). - The shared volumeset is JuiceFS, mounted drwxrwxrwx root:root, and chown WORKS on it. The spec's `--no-same-owner` mitigation was harmful: it leaves root-owned dirs www-data cannot write into. Seed uses --owner www-data. - No filesystemGroupId needed (mount is world-writable, container runs root). - wp_install() driven from CLI verified end to end, with login + a wrong-password negative control, and the first-boot-only claim measured across a restart. - curl present in the image; wget is not. Co-Authored-By: Claude Opus 5 --- briefings/wordpress.md | 116 ++++++++++ wordpress/icon.png | Bin 0 -> 55068 bytes wordpress/versions/1.0.0/Chart.yaml | 20 ++ wordpress/versions/1.0.0/README.md | 211 ++++++++++++++++++ .../versions/1.0.0/templates/_helpers.tpl | 183 +++++++++++++++ .../versions/1.0.0/templates/identity.yaml | 5 + .../versions/1.0.0/templates/policy-gvc.yaml | 19 ++ .../versions/1.0.0/templates/policy.yaml | 22 ++ .../1.0.0/templates/secret-db-root.yaml | 14 ++ .../versions/1.0.0/templates/secret-db.yaml | 17 ++ .../1.0.0/templates/secret-install.yaml | 56 +++++ .../1.0.0/templates/secret-php-ini.yaml | 16 ++ .../1.0.0/templates/secret-start.yaml | 192 ++++++++++++++++ .../versions/1.0.0/templates/volumeset.yaml | 23 ++ .../versions/1.0.0/templates/workload.yaml | 151 +++++++++++++ wordpress/versions/1.0.0/values.yaml | 83 +++++++ 16 files changed, 1128 insertions(+) create mode 100644 briefings/wordpress.md create mode 100644 wordpress/icon.png create mode 100644 wordpress/versions/1.0.0/Chart.yaml create mode 100644 wordpress/versions/1.0.0/README.md create mode 100644 wordpress/versions/1.0.0/templates/_helpers.tpl create mode 100644 wordpress/versions/1.0.0/templates/identity.yaml create mode 100644 wordpress/versions/1.0.0/templates/policy-gvc.yaml create mode 100644 wordpress/versions/1.0.0/templates/policy.yaml create mode 100644 wordpress/versions/1.0.0/templates/secret-db-root.yaml create mode 100644 wordpress/versions/1.0.0/templates/secret-db.yaml create mode 100644 wordpress/versions/1.0.0/templates/secret-install.yaml create mode 100644 wordpress/versions/1.0.0/templates/secret-php-ini.yaml create mode 100644 wordpress/versions/1.0.0/templates/secret-start.yaml create mode 100644 wordpress/versions/1.0.0/templates/volumeset.yaml create mode 100644 wordpress/versions/1.0.0/templates/workload.yaml create mode 100644 wordpress/versions/1.0.0/values.yaml diff --git a/briefings/wordpress.md b/briefings/wordpress.md new file mode 100644 index 00000000..6dcacbc8 --- /dev/null +++ b/briefings/wordpress.md @@ -0,0 +1,116 @@ +# wordpress — maintainer briefing + +## What it is + +- **WordPress** — the dominant open-source content management system; this template runs a full + site plus its database on Control Plane. +- **GPLv2 or later** (strong copyleft: modifications you distribute must stay open — it does not + attach to us, since we deploy the unmodified upstream image onto the user's own + infrastructure). Free, nothing to register, no gated edition. + +## Common use cases + +- A company blog, marketing site or documentation site the marketing team edits themselves. +- A customer-facing site that must sit inside the user's own org and network boundary rather + than on a third-party host. +- Migrating an existing WordPress site in: bring the database dump (set `tablePrefix` to match) + and copy the media library onto the volume. +- A WooCommerce/plugin-driven app where the plugin ecosystem, not the CMS, is the point. + +## Architecture on cpln + +| Resource | Purpose | +|---|---| +| `{release}-wordpress` workload (`standard`) | Apache + mod_php + WordPress on :80 | +| `{release}-wordpress-vs` volumeset (**`shared`**, RWX) | `/var/www/html` — core, plugins, themes, uploads, `wp-config.php` | +| `{release}-wordpress-start` / `-install` / `-php-ini` secrets | boot wrapper, first-run installer, PHP limits | +| `{release}-wordpress-identity` + 2 policies | `reveal` on its 5 secrets; `view` on the one GVC (boot location guard) | +| DB credentials (dictionary) + root password (opaque) secrets | created by **this** chart, read by the MariaDB subchart | +| `{release}-maria` workload + `{release}-maria-vs` (`ext4`) | bundled MariaDB 11 (subchart `mariadb` 1.4.1) | + +- The volumeset is **`shared` (read-write-many), not block** — that is the whole reason + multi-replica is possible: a block volumeset gives each replica its **own** volume, so two + replicas would mean two divergent media libraries and two sets of auth salts. +- The admin account is created **before Apache binds**, so there is never a reachable + `/wp-admin/install.php` for a stranger to claim; that is why public access can default on. + +## Key knobs + +| Knob | Default | Note | +|---|---|---| +| `wordpress.image` | `wordpress:7.1.0-php8.4-apache` | seeds the docroot on **first boot only** | +| `wordpress.replicas` | `1` | ≥2 = redundant web tier; database is still single | +| `wordpress.siteUrl` | `""` | empty = the auto-assigned `*.cpln.app` URL | +| `wordpress.siteTitle` / `tablePrefix` / `debug` | `My WordPress Site` / `wp_` / `false` | title and prefix are first-boot only | +| `php.uploadMaxSize` / `php.memoryLimit` | `64M` / `256M` | stock php.ini caps uploads at 2M | +| `admin.secretName` | `my-wordpress-admin` | **prerequisite** dictionary secret: `username`, `password`, `email` | +| `volumeset.capacity` | `10` (GiB) | minimum is 10 | +| `publicAccess.enabled` / `internalAccess.type` | `true` / `same-gvc` | firewall changes take ~30 s to ~10 min | +| `mariadb.*` | bundled MariaDB 11 | full pass-through, incl. `mariadb.backup.enabled: true` | + +## Troubleshooting / considerations + +- **Bumping `wordpress.image` does NOT upgrade WordPress core on an existing install.** The image + seeds the docroot only when the volume is empty; after that WordPress owns its files and the + site owner updates core from the dashboard (upstream's own model). A tag bump changes PHP and + Apache only. "I updated the template and WordPress still says 7.1" is working as designed. + Note the image tag reads `7.1.0` while `$wp_version` reads `7.1` — expected, not a wrong image. +- **The media library has no backup.** The bundled database can be backed up on a schedule + (`mariadb.backup.enabled: true`), but a `shared` volumeset is expand-only with no verified + snapshot/restore path, so uploads exist in exactly one place. The README says so plainly + rather than documenting a restore we have not proven. +- **A missing `admin.secretName` secret wedges the install almost silently** — `cpln logs` + returns *zero* lines because the container never starts. The only diagnostic is + `status.versions[].message` from `cpln workload get-deployments`. It self-heals in roughly + 5–10 minutes after the secret is created, or immediately with a forced redeployment. +- **The admin password is applied on first boot only** — measured, not assumed: after a restart + with the ORIGINAL password still in the container env, a password changed in between still + worked. WordPress is in the `keycloak`/`metabase` class, not the `n8n` class. Lost-password + recovery is the documented `php -r 'wp_set_password(…, 1)'` exec (verified verbatim, with a + negative control), not email. +- **Outbound email does not work out of the box.** The container has no mail transport, so + `wp_mail()` — including password reset and invites — fails silently until an SMTP plugin is + installed. Most likely "WordPress is broken" report. +- **This template expects a single-location GVC.** On a two-location GVC the platform would run + one WordPress *and* one MariaDB per location, each with its own volume — silent data + divergence. The container reads its own GVC at boot and **hard-fails on a fresh install**, + naming the locations; on an already-initialised docroot it only warns, so the check can never + take a live site down. If the GVC read fails (e.g. the GVC policy was removed) it warns and + continues — a control-plane hiccup must not stop a website booting. +- **Cold start elects one replica.** With several replicas on one RWX docroot, a naive cold start + would have every replica seed and install at once, so `start.sh` takes an atomic `mkdir` lock; + the loser waits for a done-marker (bounded, then releases a stale lock and exits so the + platform restarts it). Verified with two replicas racing on one volume: one seeded, one waited + 5 s, exactly one admin user was created. +- **`cpln workload exec … php -r` does not see `WP_SITE_URL`.** It is exported by `start.sh`, so + only Apache's process tree has it; an `exec` gets a fresh process and `WP_HOME` reads as + undefined there. Harmless for the documented password reset, but do not diagnose the site URL + that way — check it over HTTP instead. +- **The first `helm upgrade` after an install may bounce the bundled MariaDB** (probabilistic + across the catalog). At `replicas: 1` the site returns errors for a minute or two while the + database comes back. Check whether the database is restarting before diagnosing a credential + problem. +- **Availability posture, plainly:** `replicas: 2` removes the web tier as a single point of + failure — replicas are independent PHP servers sharing one database and one filesystem, with + cookie-based auth signed by salts in the shared `wp-config.php`, so no session affinity or peer + discovery is needed. The **bundled MariaDB remains single-instance** and is the availability + ceiling; there is no HA MariaDB template to depend on yet. +- **Plugin installs write to the shared volume** and are picked up by other replicas within + ~2 seconds (PHP opcache `revalidate_freq=2`). If a plugin install ever asks for **FTP + credentials**, `FS_METHOD = 'direct'` is not reaching WordPress — check `WORDPRESS_CONFIG_EXTRA` + on the container. + +## Platform facts measured while building this (worth reusing) + +- **A `standard` workload MAY mount a `shared` volumeset** — it reached `ready: true`. The + `stateful`/`vm` restriction applies to block (`ext4`/`xfs`) volumesets only. No template had + done this before. +- **A `shared` volumeset is JuiceFS, mounted `drwxrwxrwx` root:root, and `chown` works on it.** + So the upstream entrypoint's unguarded `chown .` would *not* have crashed, and no + `filesystemGroupId` is needed. The spec's `--no-same-owner` mitigation was actively harmful: + it leaves root-owned directories that `www-data` cannot write into, which would have broken + plugin installs and media uploads. The chart seeds with `--owner www-data --group www-data`. +- **The API backfills `autoscaling.target: 95`**, not 100, plus `maxConcurrency: 0` and + `scaleToZeroDelay: 300`, on a partial `defaultOptions.autoscaling` block. Omitting + `rolloutOptions` entirely stored no `rolloutOptions` at all. +- The image has `curl` but **no `wget`**, and `/usr/src/wordpress` is 117 MB (not ~250 MB). diff --git a/wordpress/icon.png b/wordpress/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..45e8961d8f99c87b0a2d78d99ba430c81ede0a1a GIT binary patch literal 55068 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

~r4RAq;T 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 share one uploads volume and one database; see README "Availability" + siteTitle: My WordPress Site # set on first boot only; change later in Settings → General + siteUrl: "" # public base URL; empty = the auto-assigned *.cpln.app endpoint + tablePrefix: wp_ # change only when importing an existing WordPress database + debug: false # WP_DEBUG — PHP notices to the container log; leave off in production + resources: + minCpu: 250m + maxCpu: 1000m + minMemory: 512Mi + maxMemory: 1Gi + +``` + +### PHP limits + +```yaml +# ─── PHP limits ─────────────────────────────────────────────────────────────── +# Stock php.ini caps uploads at 2M, which blocks most media uploads. +php: + uploadMaxSize: 64M # upload_max_filesize and post_max_size + memoryLimit: 256M # PHP memory_limit; some plugins want 256M in wp-admin + +``` + +### Admin account + +```yaml +# ─── Admin account (REQUIRED PREREQUISITE SECRET) ───────────────────────────── +# CREATE IT BEFORE YOU INSTALL. A `dictionary` secret with exactly three keys: +# `username`, `password`, `email`. Applied on FIRST BOOT ONLY — rotating it later +# does NOT change your login. If it does not exist the deployment WEDGES silently +# and `cpln logs` returns nothing; the only diagnostic is status.versions[].message +# from `cpln workload get-deployments`. +admin: + secretName: my-wordpress-admin + +``` + +### Content storage + +```yaml +# ─── Content storage ────────────────────────────────────────────────────────── +# One read-write-many volume per location, mounted at /var/www/html by every +# replica: core, plugins, themes, uploads and wp-config.php. Shared volumes can be +# expanded but NOT snapshotted — the media library has no backup, keep your own copy. +volumeset: + capacity: 10 # initial capacity in GiB (minimum is 10) + +``` + +### Access + +```yaml +# ─── Access ─────────────────────────────────────────────────────────────────── +# A firewall change takes ~30 s to a few minutes to propagate. +publicAccess: + enabled: true # HTTPS site on the auto-assigned *.cpln.app endpoint +internalAccess: + type: same-gvc # none | same-gvc | same-org | workload-list + workloads: [] # used only with workload-list + # workloads: + # - //gvc/GVC_NAME/workload/WORKLOAD_NAME + +``` + +### MariaDB (bundled database) + +```yaml +# ─── MariaDB (subchart: mariadb) — posts, pages, users, settings ────────────── +# Every knob of the `mariadb` template is available under this key — including its +# native scheduled backups and phpMyAdmin console, with nothing extra to install: +# mariadb.backup.enabled: true +# mariadb.backup.provider: aws | gcp +# mariadb.backup.aws.bucket / .region / .cloudAccountName / .policyName +# mariadb.phpMyAdmin.enabled: true +# See "Backing up the bundled database" in the README. +mariadb: + image: mariadb:11 + # Credentials for the bundled database — internal plumbing no human types + # elsewhere. This chart CREATES both secrets named below out of these values, + # so there is nothing for you to create before installing. + credentials: + username: wordpress + password: change-me-wordpress-db # change before installing + database: wordpress + rootPassword: change-me-wordpress-db-root # change before installing + # Names of the two secrets this chart creates and the bundled MariaDB reads. + # Secret names are org-wide: give each wordpress release its own names. A second + # release left on these names is REFUSED at install (they are owned by the first + # release) — nothing is shared, overwritten or deleted. + credentialsSecretName: my-wordpress-db-credentials + rootPasswordSecretName: my-wordpress-db-root-password + resources: + minCpu: 150m # keeps cpu:minCpu under the stateful 4:1 cap (500/150 = 3.3:1) + maxCpu: 500m + minMemory: 256Mi + maxMemory: 1Gi + volumeset: + capacity: 10 # initial capacity in GiB (minimum is 10) + internalAccess: + type: same-gvc # WordPress must be able to reach the database +``` + +## Connecting + +| What | Where | +|---|---| +| Public site | The auto-assigned `*.cpln.app` endpoint (`publicAccess.enabled: true`). Read the exact URL from `status.canonicalEndpoint` of `cpln workload get RELEASE-wordpress --gvc YOUR-GVC -o yaml`. | +| Admin dashboard | `/wp-admin` | +| Admin credentials | The `username` / `password` you put in the `admin.secretName` dictionary secret | +| From another workload in the GVC | `http://RELEASE-wordpress.YOUR-GVC.cpln.local` (port 80) | +| Health endpoint | `/cpln-health.php` — returns `ok` when PHP, the database and the install are all good | +| Database (internal only) | `RELEASE-maria.YOUR-GVC.cpln.local:3306`, credentials in the `mariadb.credentialsSecretName` secret | + +With `publicAccess.enabled: false` the site is still reachable in a browser through a tunnel: + +```bash +cpln port-forward RELEASE-wordpress 8080:80 --gvc YOUR-GVC +``` + +## Availability + +`wordpress.replicas: 2` or more removes the web tier as a single point of failure. Replicas are independent PHP servers sharing one database and one filesystem; authentication is cookie-based and signed with the salts in the shared `wp-config.php`, so any replica accepts any other's cookie and no session affinity is needed. + +**The bundled MariaDB stays single-instance and is the availability ceiling.** With more replicas the web tier survives a replica loss and a rolling restart; the database does not. There is no HA MariaDB template to depend on yet. + +## Backups + +**The database can be backed up on a schedule. The media library cannot.** Be clear on this before you put a real site here: + +- **Database — supported.** Every knob of the `mariadb` template is available under the `mariadb` key, including its native scheduled backups to AWS S3 or GCS. Set `mariadb.backup.enabled: true`, pick `mariadb.backup.provider`, and fill in the bucket, region, [cloud account](https://docs.controlplane.com/guides/create-cloud-account) and IAM policy under `mariadb.backup.aws` or `mariadb.backup.gcp`. The `mariadb` template's own README carries the per-provider bucket and IAM setup steps. +- **Media library — not backed up, and there is no snapshot path we can vouch for.** The docroot is a `shared` volumeset, which can be expanded but not snapshotted. Uploads, installed plugins and themes therefore exist in exactly one place. Keep your own copy, and do not assume a reinstall can recover it. + +A database backup on its own restores your posts, pages, users and settings, but the images those posts reference will be missing unless you kept the media library yourself. + +## Recovering a lost admin password + +The admin account is created on **first boot only**, so rotating the `admin.secretName` secret does not change anyone's login. Reset the password directly instead (user ID `1` is the administrator created at install): + +```bash +cpln workload exec RELEASE-wordpress --gvc YOUR-GVC --container wordpress -- \ + php -r 'require "/var/www/html/wp-load.php"; wp_set_password("YOUR-NEW-PASSWORD", 1);' +``` + +WordPress's own "lost password" email cannot help here — see the note on outbound email below. + +## Important Notes + +- **Create the `admin.secretName` dictionary secret before installing.** A missing secret wedges the deployment with zero log output; read `status.versions[].message` from `cpln workload get-deployments` to see which secret is missing. +- **Change `mariadb.credentials.password` and `mariadb.credentials.rootPassword` before installing.** They ship as obviously-invalid `change-me-…` placeholders and are used as-is. +- **Bumping `wordpress.image` does NOT upgrade WordPress core on an existing install.** The image seeds the docroot only while it is empty; after that WordPress owns its files and you update core from the dashboard. A tag bump changes PHP and Apache only. +- **Outbound email does not work out of the box.** The container has no mail transport, so `wp_mail()` — including password reset and user invites — fails silently. Install an SMTP plugin before you rely on any email. +- **Install into a single-location GVC.** A fresh install refuses a multi-location GVC; an already-running site only warns, so the check can never take a live site down. +- **`wordpress.siteTitle` and `wordpress.tablePrefix` apply on first boot only.** Change the title later in Settings → General; the table prefix cannot be changed after install. +- **Give each release its own `mariadb.credentialsSecretName` and `mariadb.rootPasswordSecretName`.** Secret names are org-wide, and a second release left on the defaults is refused at install. +- **A firewall change takes ~30 seconds to several minutes to propagate.** After flipping `publicAccess.enabled` or `internalAccess.type`, keep re-polling rather than concluding the knob is broken. + +## Links + +- [WordPress documentation](https://wordpress.org/documentation/) +- [Advanced administration handbook](https://developer.wordpress.org/advanced-administration/) +- [WordPress requirements](https://wordpress.org/about/requirements/) +- [`wp-config.php` reference](https://developer.wordpress.org/apis/wp-config-php/) +- [Official WordPress Docker image](https://hub.docker.com/_/wordpress) diff --git a/wordpress/versions/1.0.0/templates/_helpers.tpl b/wordpress/versions/1.0.0/templates/_helpers.tpl new file mode 100644 index 00000000..1b6a6d4d --- /dev/null +++ b/wordpress/versions/1.0.0/templates/_helpers.tpl @@ -0,0 +1,183 @@ +{{/* Resource Naming */}} + +{{- define "wordpress.name" -}} +{{- printf "%s-wordpress" .Release.Name }} +{{- end }} + +{{- define "wordpress.volume.name" -}} +{{- printf "%s-wordpress-vs" .Release.Name }} +{{- end }} + +{{- define "wordpress.identity.name" -}} +{{- printf "%s-wordpress-identity" .Release.Name }} +{{- end }} + +{{- define "wordpress.policy.name" -}} +{{- printf "%s-wordpress-policy" .Release.Name }} +{{- end }} + +{{- define "wordpress.policy.gvc.name" -}} +{{- printf "%s-wordpress-gvc-policy" .Release.Name }} +{{- end }} + +{{/* Boot wrapper: GVC guard -> docroot seed -> wp-config -> install -> Apache. */}} +{{- define "wordpress.secret.start.name" -}} +{{- printf "%s-wordpress-start" .Release.Name }} +{{- end }} + +{{/* First-run installer, run before Apache binds. */}} +{{- define "wordpress.secret.install.name" -}} +{{- printf "%s-wordpress-install" .Release.Name }} +{{- end }} + +{{/* PHP ini overlay (upload size, memory limit). */}} +{{- define "wordpress.secret.phpini.name" -}} +{{- printf "%s-wordpress-php-ini" .Release.Name }} +{{- end }} + +{{/* +Names of the two secrets this chart CREATES for the bundled mariadb subchart. +mariadb 1.4.0 stopped creating its own secrets and now takes only their NAMES, +and a parent cannot template a subchart value — so the names are plain values +that both sides read. +*/}} +{{- define "wordpress.secret.db.name" -}} +{{- .Values.mariadb.credentialsSecretName }} +{{- end }} + +{{- define "wordpress.secret.dbRoot.name" -}} +{{- .Values.mariadb.rootPasswordSecretName }} +{{- end }} + + +{{/* Dependency Helpers (deterministic on .Release.Name — mirrors the subchart helper) */}} + +{{/* +Hostname of the bundled MariaDB (the mariadb subchart's `maria.name` helper → +{release}-maria), on port 3306. Always the FQDN: the bare short name is not +reliable and is workload-type dependent. +*/}} +{{- define "wordpress.mariadb.host" -}} +{{- printf "%s-maria.%s.cpln.local" .Release.Name .Values.global.cpln.gvc }} +{{- end }} + +{{/* +Every workload this release creates, as workload links. + +Defined ONCE and appended at every `workload-list` call site, so an +`internalAccess.type: workload-list` can never silently cut this release off +from itself. Each member is gated on the toggle that creates it, so the list +never names a workload that was not rendered. + +Nothing in this release currently calls INTO WordPress (the traffic runs the +other way, WordPress → MariaDB, and is governed by `mariadb.internalAccess`), so +these entries are defensive rather than load-bearing today. They are here +because a hand-maintained list is exactly what drifts when that changes. +*/}} +{{- define "wordpress.ownWorkloadLinks" -}} +{{- $gvc := .Values.global.cpln.gvc -}} +- //gvc/{{ $gvc }}/workload/{{ include "wordpress.name" . }} +- //gvc/{{ $gvc }}/workload/{{ .Release.Name }}-maria +{{- if .Values.mariadb.phpMyAdmin }}{{- if .Values.mariadb.phpMyAdmin.enabled }} +- //gvc/{{ $gvc }}/workload/{{ .Release.Name }}-phpmyadmin +{{- end }}{{- end }} +{{- if .Values.mariadb.backup }}{{- if .Values.mariadb.backup.enabled }} +- //gvc/{{ $gvc }}/workload/{{ .Release.Name }}-maria-backup +{{- end }}{{- end }} +{{- end }} + + +{{/* Labeling */}} + +{{- define "wordpress.tags" -}} +{{- include "cpln-common.tags" . }} +{{- end }} + + +{{/* Validation */}} + +{{- define "wordpress.validate" -}} + +{{- /* Admin account — a REQUIRED prerequisite secret, never a values default. */ -}} +{{- if not .Values.admin.secretName -}} +{{- fail "wordpress: admin.secretName is required — the name of a prerequisite `dictionary` secret holding exactly the keys `username`, `password` and `email`. It MUST exist BEFORE install: the admin account is created before Apache binds, and a missing secret wedges the deployment silently (cpln logs returns nothing — read status.versions[].message from `cpln workload get-deployments`)" -}} +{{- end -}} + +{{- /* Bundled-database plumbing this chart creates. */ -}} +{{- if not .Values.mariadb.credentialsSecretName -}} +{{- fail "wordpress: mariadb.credentialsSecretName is required — this chart CREATES that dictionary secret from mariadb.credentials.*, and the bundled MariaDB reads it by name. Secret names are org-wide, so give each wordpress release its own name" -}} +{{- end -}} +{{- if not .Values.mariadb.rootPasswordSecretName -}} +{{- fail "wordpress: mariadb.rootPasswordSecretName is required — this chart CREATES that opaque secret from mariadb.credentials.rootPassword, and the bundled MariaDB reads it by name. Secret names are org-wide, so give each wordpress release its own name" -}} +{{- end -}} +{{- if not .Values.mariadb.credentials.username -}} +{{- fail "wordpress: mariadb.credentials.username is required" -}} +{{- end -}} +{{- if not .Values.mariadb.credentials.password -}} +{{- fail "wordpress: mariadb.credentials.password is required" -}} +{{- end -}} +{{- if not .Values.mariadb.credentials.database -}} +{{- fail "wordpress: mariadb.credentials.database is required" -}} +{{- end -}} +{{- if not .Values.mariadb.credentials.rootPassword -}} +{{- fail "wordpress: mariadb.credentials.rootPassword is required" -}} +{{- end -}} +{{- if eq .Values.mariadb.credentialsSecretName .Values.mariadb.rootPasswordSecretName -}} +{{- fail "wordpress: mariadb.credentialsSecretName and mariadb.rootPasswordSecretName must be DIFFERENT secrets — the application credential is deliberately separate from root, so sharing the first does not hand out the second" -}} +{{- end -}} + +{{- /* Replicas. */ -}} +{{- if lt (int .Values.wordpress.replicas) 1 -}} +{{- fail (printf "wordpress: wordpress.replicas must be at least 1, got '%v'" .Values.wordpress.replicas) -}} +{{- end -}} + +{{- /* Table prefix: WordPress requires a non-empty, [A-Za-z0-9_] prefix. */ -}} +{{- if not .Values.wordpress.tablePrefix -}} +{{- fail "wordpress: wordpress.tablePrefix must not be empty (WordPress default is 'wp_')" -}} +{{- end -}} +{{- if not (regexMatch "^[A-Za-z0-9_]+$" .Values.wordpress.tablePrefix) -}} +{{- fail (printf "wordpress: wordpress.tablePrefix may contain only letters, numbers and underscores, got '%s'" .Values.wordpress.tablePrefix) -}} +{{- end -}} + +{{- /* Site URL must carry a scheme — it becomes WP_HOME/WP_SITEURL verbatim. */ -}} +{{- if .Values.wordpress.siteUrl -}} +{{- if not (or (hasPrefix "http://" .Values.wordpress.siteUrl) (hasPrefix "https://" .Values.wordpress.siteUrl)) -}} +{{- fail (printf "wordpress: wordpress.siteUrl must start with http:// or https:// — it is used verbatim as WP_HOME and WP_SITEURL, got '%s'" .Values.wordpress.siteUrl) -}} +{{- end -}} +{{- if hasSuffix "/" .Values.wordpress.siteUrl -}} +{{- fail (printf "wordpress: wordpress.siteUrl must not end with a trailing slash, got '%s'" .Values.wordpress.siteUrl) -}} +{{- end -}} +{{- end -}} + +{{- /* Volume capacity. */ -}} +{{- if lt (int .Values.volumeset.capacity) 10 -}} +{{- fail (printf "wordpress: volumeset.capacity must be at least 10 (GiB), got '%v'" .Values.volumeset.capacity) -}} +{{- end -}} + +{{- /* Access. */ -}} +{{- if not (has .Values.internalAccess.type (list "none" "same-gvc" "same-org" "workload-list")) -}} +{{- fail (printf "wordpress: internalAccess.type must be 'none', 'same-gvc', 'same-org' or 'workload-list', got '%s'" .Values.internalAccess.type) -}} +{{- end -}} +{{- if and (eq .Values.internalAccess.type "workload-list") (not .Values.internalAccess.workloads) -}} +{{- fail "wordpress: internalAccess.workloads must list at least one workload link when internalAccess.type is 'workload-list', e.g. //gvc/GVC_NAME/workload/WORKLOAD_NAME (this release's own workloads are added automatically)" -}} +{{- end -}} + +{{- /* + The database is reached over the GVC's internal network. If the user narrows + the bundled MariaDB to a workload list that omits this WordPress workload, the + site cannot reach its own database — and it fails as a boot hang, not as an + error, so catch it at render instead. +*/ -}} +{{- if .Values.mariadb.internalAccess -}} +{{- if eq (.Values.mariadb.internalAccess.type | default "") "workload-list" -}} +{{- $self := printf "//gvc/%s/workload/%s" .Values.global.cpln.gvc (include "wordpress.name" .) -}} +{{- if not (has $self (.Values.mariadb.internalAccess.workloads | default list)) -}} +{{- fail (printf "wordpress: mariadb.internalAccess.type is 'workload-list' but the list does not include this release's WordPress workload — add '%s', or the site cannot reach its own database" $self) -}} +{{- end -}} +{{- end -}} +{{- if eq (.Values.mariadb.internalAccess.type | default "") "none" -}} +{{- fail "wordpress: mariadb.internalAccess.type must not be 'none' — WordPress reaches the bundled database over the GVC internal network. Use 'same-gvc' (default) or 'workload-list' including this release's WordPress workload" -}} +{{- end -}} +{{- end -}} + +{{- end }} diff --git a/wordpress/versions/1.0.0/templates/identity.yaml b/wordpress/versions/1.0.0/templates/identity.yaml new file mode 100644 index 00000000..5e1b3a5e --- /dev/null +++ b/wordpress/versions/1.0.0/templates/identity.yaml @@ -0,0 +1,5 @@ +kind: identity +name: {{ include "wordpress.identity.name" . }} +description: WordPress workload identity +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "wordpress.tags" . | nindent 4 }} diff --git a/wordpress/versions/1.0.0/templates/policy-gvc.yaml b/wordpress/versions/1.0.0/templates/policy-gvc.yaml new file mode 100644 index 00000000..00a8937e --- /dev/null +++ b/wordpress/versions/1.0.0/templates/policy-gvc.yaml @@ -0,0 +1,19 @@ +{{- /* + The container reads its OWN GVC at boot to confirm it has exactly one location. + On a multi-location GVC the platform would run one WordPress AND one MariaDB + per location, each with its own volume — the media library and the database + split silently. Scoped to the ONE install GVC; never `target: all`, which would + grant view on every GVC in the org. +*/}} +kind: policy +name: {{ include "wordpress.policy.gvc.name" . }} +description: WordPress GVC read policy (single-location boot guard) +tags: {{- include "wordpress.tags" . | nindent 4 }} +bindings: + - permissions: + - view + principalLinks: + - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "wordpress.identity.name" . }} +targetKind: gvc +targetLinks: + - //gvc/{{ .Values.global.cpln.gvc }} diff --git a/wordpress/versions/1.0.0/templates/policy.yaml b/wordpress/versions/1.0.0/templates/policy.yaml new file mode 100644 index 00000000..36a773c5 --- /dev/null +++ b/wordpress/versions/1.0.0/templates/policy.yaml @@ -0,0 +1,22 @@ +{{- /* + `reveal` on exactly the secrets the WordPress container reads — and no others. + The bundled database's ROOT password is deliberately absent: the application + connects as the unprivileged user, and the MariaDB subchart's own identity + already covers root. +*/}} +kind: policy +name: {{ include "wordpress.policy.name" . }} +description: WordPress secret access +tags: {{- include "wordpress.tags" . | nindent 4 }} +bindings: + - permissions: + - reveal + principalLinks: + - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "wordpress.identity.name" . }} +targetKind: secret +targetLinks: + - //secret/{{ include "wordpress.secret.start.name" . }} + - //secret/{{ include "wordpress.secret.install.name" . }} + - //secret/{{ include "wordpress.secret.phpini.name" . }} + - //secret/{{ include "wordpress.secret.db.name" . }} + - //secret/{{ .Values.admin.secretName }} diff --git a/wordpress/versions/1.0.0/templates/secret-db-root.yaml b/wordpress/versions/1.0.0/templates/secret-db-root.yaml new file mode 100644 index 00000000..1819a5cb --- /dev/null +++ b/wordpress/versions/1.0.0/templates/secret-db-root.yaml @@ -0,0 +1,14 @@ +{{- /* + The bundled database's root password, kept in a SEPARATE secret from the + application credentials so the WordPress policy can grant `reveal` on the + application credential without also handing out root. Only the MariaDB + subchart's own identity can read this one. +*/}} +kind: secret +name: {{ include "wordpress.secret.dbRoot.name" . }} +description: WordPress bundled MariaDB root password +tags: {{- include "wordpress.tags" . | nindent 4 }} +type: opaque +data: + encoding: plain + payload: {{ .Values.mariadb.credentials.rootPassword | quote }} diff --git a/wordpress/versions/1.0.0/templates/secret-db.yaml b/wordpress/versions/1.0.0/templates/secret-db.yaml new file mode 100644 index 00000000..bdaed43d --- /dev/null +++ b/wordpress/versions/1.0.0/templates/secret-db.yaml @@ -0,0 +1,17 @@ +{{- /* + Credentials for the BUNDLED database, in the shape the mariadb subchart reads: + exactly the keys username, password and database. Internal plumbing — no human + ever types this password — so the bundled-plumbing exception applies and it + stays a template-created secret rather than a prerequisite the user creates. + mariadb 1.4.0 stopped creating this secret and now takes only its NAME, which + is why the chart on this side of the dependency owns it. +*/}} +kind: secret +name: {{ include "wordpress.secret.db.name" . }} +description: WordPress bundled MariaDB application credentials +tags: {{- include "wordpress.tags" . | nindent 4 }} +type: dictionary +data: + username: {{ .Values.mariadb.credentials.username | quote }} + password: {{ .Values.mariadb.credentials.password | quote }} + database: {{ .Values.mariadb.credentials.database | quote }} diff --git a/wordpress/versions/1.0.0/templates/secret-install.yaml b/wordpress/versions/1.0.0/templates/secret-install.yaml new file mode 100644 index 00000000..689d0ff9 --- /dev/null +++ b/wordpress/versions/1.0.0/templates/secret-install.yaml @@ -0,0 +1,56 @@ +{{- /* + First-run installer, run by start.sh BEFORE Apache binds — so there is never a + reachable /wp-admin/install.php for a stranger to claim, which is what lets + publicAccess default to true. + + Credentials arrive as ENV (resolved by the platform from the prerequisite + admin secret) and never appear on a command line or in a log. + + wp_install() does NOT guard itself, so the is_blog_installed() check is + mandatory: without it a restart on an installed site would attempt a second + install. +*/}} +kind: secret +name: {{ include "wordpress.secret.install.name" . }} +description: WordPress first-run installer +tags: {{- include "wordpress.tags" . | nindent 4 }} +type: opaque +data: + encoding: plain + payload: |- + $user, 'WP_ADMIN_PASSWORD' => $pass, 'WP_ADMIN_EMAIL' => $mail] as $k => $v) { + if ($v === false || $v === '') { + fwrite(STDERR, "cpln-bootstrap: $k is empty - the admin secret must hold the keys username, password and email\n"); + exit(1); + } + } + + $result = wp_install(getenv('WP_SITE_TITLE') ?: 'WordPress', $user, $mail, true, '', $pass); + + if (is_wp_error($result)) { + fwrite(STDERR, "cpln-bootstrap: install failed: " . $result->get_error_message() . "\n"); + exit(1); + } + fwrite(STDERR, "cpln-bootstrap: WordPress installed; admin user '" . $user . "' created\n"); diff --git a/wordpress/versions/1.0.0/templates/secret-php-ini.yaml b/wordpress/versions/1.0.0/templates/secret-php-ini.yaml new file mode 100644 index 00000000..77b8effd --- /dev/null +++ b/wordpress/versions/1.0.0/templates/secret-php-ini.yaml @@ -0,0 +1,16 @@ +{{- /* + Mounted at /usr/local/etc/php/conf.d/zz-cpln-wordpress.ini. The `zz-` prefix + sorts it after the image's own opcache-recommended.ini and error-logging.ini, + so these win. Without it the stock php.ini-production caps uploads at 2M. +*/}} +kind: secret +name: {{ include "wordpress.secret.phpini.name" . }} +description: WordPress PHP limits (upload size, memory limit) +tags: {{- include "wordpress.tags" . | nindent 4 }} +type: opaque +data: + encoding: plain + payload: |- + upload_max_filesize = {{ .Values.php.uploadMaxSize }} + post_max_size = {{ .Values.php.uploadMaxSize }} + memory_limit = {{ .Values.php.memoryLimit }} diff --git a/wordpress/versions/1.0.0/templates/secret-start.yaml b/wordpress/versions/1.0.0/templates/secret-start.yaml new file mode 100644 index 00000000..d2fe463d --- /dev/null +++ b/wordpress/versions/1.0.0/templates/secret-start.yaml @@ -0,0 +1,192 @@ +{{- /* + Boot wrapper, run as `bash /cpln/start.sh`. Ordered contract: + 1. single-location GVC guard 2. docroot seed (one replica only) + 3. wp-config.php generation 4. wait for MariaDB + 5. first-run install 6. health endpoint 7. exec Apache + + Why a wrapper at all: with several replicas sharing one read-write-many + docroot, a cold start would have every replica seed and install at once. The + image's own entrypoint has no election, so this script performs one. +*/}} +kind: secret +name: {{ include "wordpress.secret.start.name" . }} +description: WordPress boot wrapper (GVC guard, docroot seed, first-run install) +tags: {{- include "wordpress.tags" . | nindent 4 }} +type: opaque +data: + encoding: plain + payload: |- + #!/bin/bash + set -Eeuo pipefail + + DOCROOT=/var/www/html + LOCK="$DOCROOT/.cpln-bootstrap.lock" + DONE="$DOCROOT/.cpln-bootstrap-done" + cd "$DOCROOT" + + log() { echo "cpln-bootstrap: $*" >&2; } + + # ── 0. Site URL ────────────────────────────────────────────────────────────── + # WP_HOME/WP_SITEURL are derived from this via WORDPRESS_CONFIG_EXTRA, which is + # read on EVERY request -- so a later `helm upgrade` changes the site URL even + # though wp-config.php was generated on first boot. +{{- if .Values.wordpress.siteUrl }} + export WP_SITE_URL="{{ .Values.wordpress.siteUrl }}" +{{- else if .Values.publicAccess.enabled }} + # CPLN_GLOBAL_ENDPOINT is already a full https:// URL, NOT a bare host -- + # use it verbatim, never prepend a second scheme, never assemble it from parts. + export WP_SITE_URL="${CPLN_GLOBAL_ENDPOINT:-}" +{{- else }} + # No public endpoint -- links point at the internal in-GVC address. + export WP_SITE_URL="http://{{ include "wordpress.name" . }}.{{ .Values.global.cpln.gvc }}.cpln.local" +{{- end }} + + # ── 1. Single-location GVC guard ───────────────────────────────────────────── + # On a multi-location GVC the platform starts this workload AND the bundled + # MariaDB in every location: two databases with two independent volumes behind + # one DNS name, plus two divergent upload volumes. That is silent data + # divergence, so a FRESH install refuses. An already-initialised docroot only + # warns -- this check must never take a live site down. + FRESH=no + if [ ! -e wp-includes/version.php ] && [ ! -e index.php ]; then FRESH=yes; fi + + LOCATIONS="" + if [ -n "${WP_GVC_LOCATIONS_OVERRIDE:-}" ]; then + # Test hook: lets the >1-location disposition be exercised on a + # single-location GVC. The chart never sets this. + LOCATIONS="$WP_GVC_LOCATIONS_OVERRIDE" + log "GVC location list overridden for testing: $LOCATIONS" + elif [ -n "${CPLN_TOKEN:-}" ] && [ -n "${CPLN_ORG:-}" ] && [ -n "${CPLN_GVC:-}" ]; then + # curl defaults to HTTP/1.1; $CPLN_ENDPOINT sits behind istio-envoy, which + # answers HTTP/1.0 with 426. --max-time bounds the whole call, not just connect. + GVC_JSON="$(curl -sS --connect-timeout 5 --max-time 15 --retry 2 --retry-delay 3 \ + -H "Authorization: $CPLN_TOKEN" \ + "${CPLN_ENDPOINT:-http://api.cpln.io}/org/$CPLN_ORG/gvc/$CPLN_GVC" 2>/dev/null || true)" + if [ -n "$GVC_JSON" ]; then + LOCATIONS="$(printf '%s' "$GVC_JSON" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + $l = $d["spec"]["staticPlacement"]["locationLinks"] ?? null; + if (!is_array($l) || !count($l)) { exit(1); } + echo implode(",", array_map(function ($x) { return basename($x); }, $l)); + ' 2>/dev/null || true)" + fi + fi + + if [ -z "$LOCATIONS" ]; then + # A control-plane hiccup or a missing GVC policy must not stop a site booting. + log "WARNING: could not read GVC '${CPLN_GVC:-?}' - single-location guard SKIPPED" + else + LOCATION_COUNT="$(printf '%s' "$LOCATIONS" | awk -F, '{print NF}')" + if [ "$LOCATION_COUNT" -gt 1 ]; then + if [ "$FRESH" = yes ]; then + log "FATAL: GVC '${CPLN_GVC:-?}' has $LOCATION_COUNT locations ($LOCATIONS)." + log "FATAL: This template supports a SINGLE-location GVC only. In a multi-location" + log "FATAL: GVC the platform runs one WordPress AND one MariaDB per location, each" + log "FATAL: with its own volume - the media library and the database silently split." + log "FATAL: Install into a single-location GVC instead." + exit 1 + fi + log "WARNING: GVC '${CPLN_GVC:-?}' has $LOCATION_COUNT locations ($LOCATIONS) - this site's" + log "WARNING: data may be split per location. Not failing: the docroot is already initialised." + else + log "single-location GVC ok ($LOCATIONS)" + fi + fi + + # ── 2. Bootstrap election ──────────────────────────────────────────────────── + # The docroot is read-write-many, so on a cold start every replica would seed and + # install at once. mkdir is atomic on POSIX and on network filesystems, so + # exactly one replica wins. Keyed on .cpln-bootstrap-done rather than on an empty + # docroot, so a crash between "seeded" and "installed" is still repaired. + HOLD=no + if [ ! -e "$DONE" ]; then + if mkdir "$LOCK" 2>/dev/null; then + HOLD=yes + log "this replica is bootstrapping the site" + else + log "another replica is bootstrapping; waiting..." + WAITED=0 + while [ ! -e "$DONE" ]; do + if [ "$WAITED" -ge 600 ]; then + # The holder died mid-bootstrap. Clear the lock and exit non-zero so the + # platform restarts us and this replica can take the lock next time, + # rather than wedging on an orphaned lock forever. + log "ERROR: timed out after ${WAITED}s waiting for the bootstrap replica; releasing the stale lock and restarting" + rmdir "$LOCK" 2>/dev/null || true + exit 1 + fi + sleep 5 + WAITED=$((WAITED + 5)) + done + log "bootstrap completed elsewhere after ${WAITED}s" + fi + fi + + # ── 3. Seed the docroot ────────────────────────────────────────────────────── + # We seed rather than letting the image entrypoint do it, for one reason the + # entrypoint cannot solve: with several replicas sharing one volume, only the + # lock holder above may write. Ownership is set the way the entrypoint sets it + # (tar --owner/--group www-data) -- measured working on the platform's shared + # volumeset. Do NOT use --no-same-owner: it leaves root-owned directories that + # www-data cannot write into, which breaks plugin installs and media uploads. + if [ "$HOLD" = yes ] && [ "$FRESH" = yes ]; then + log "seeding WordPress into $DOCROOT ..." + tar --create --file - --directory /usr/src/wordpress --owner www-data --group www-data . \ + | tar --extract --file - --directory "$DOCROOT" + if ! chown -R www-data:www-data "$DOCROOT" 2>/dev/null; then + log "WARNING: chown failed on this storage - falling back to a permissive mode" + chmod -R a+rwX "$DOCROOT" 2>/dev/null || log "WARNING: chmod fallback also failed; plugin installs and uploads may fail" + fi + log "seed complete ($(du -sh "$DOCROOT" 2>/dev/null | cut -f1))" + fi + + # ── 4. wp-config.php ───────────────────────────────────────────────────────── + # The image's own alias runs the setup for ANY command. It generates + # wp-config.php with eight unique auth salts (once, on the shared volume, so + # every replica signs cookies identically) and is a no-op once it exists. + docker-ensure-installed.sh true + + # ── 5. Wait for the database ───────────────────────────────────────────────── + log "waiting for the database at ${WORDPRESS_DB_HOST:-?} ..." + WAITED=0 + until php -r ' + $hp = getenv("WORDPRESS_DB_HOST"); + $port = 3306; + if (strpos($hp, ":") !== false) { list($hp, $port) = explode(":", $hp, 2); $port = (int) $port; } + mysqli_report(MYSQLI_REPORT_OFF); + $c = @mysqli_connect($hp, getenv("WORDPRESS_DB_USER"), getenv("WORDPRESS_DB_PASSWORD"), getenv("WORDPRESS_DB_NAME"), $port); + exit($c ? 0 : 1); + ' 2>/dev/null; do + if [ "$WAITED" -ge 300 ]; then + log "FATAL: database ${WORDPRESS_DB_HOST:-?} not reachable after ${WAITED}s" + exit 1 + fi + sleep 5 + WAITED=$((WAITED + 5)) + done + log "database reachable after ${WAITED}s" + + # ── 6. First-run install ───────────────────────────────────────────────────── + if [ "$HOLD" = yes ]; then + php /cpln/install.php + : > "$DONE" + rmdir "$LOCK" 2>/dev/null || true + fi + + # ── 7. Health endpoint ─────────────────────────────────────────────────────── + # Rewritten every boot so an upgrade refreshes it. Written via a temp file so a + # concurrent replica never serves a half-written file. + cat > "$DOCROOT/.cpln-health.php.tmp" <<'HEALTH' + /dev/null || true + + log "handing off to Apache" + exec apache2-foreground diff --git a/wordpress/versions/1.0.0/templates/volumeset.yaml b/wordpress/versions/1.0.0/templates/volumeset.yaml new file mode 100644 index 00000000..039c54d5 --- /dev/null +++ b/wordpress/versions/1.0.0/templates/volumeset.yaml @@ -0,0 +1,23 @@ +{{- /* + READ-WRITE-MANY on purpose. A block volumeset (ext4/xfs) gives every replica its + OWN volume, so two WordPress replicas would mean two media libraries, two plugin + sets and two wp-config.php files with two different sets of auth salts — every + user logged out on every request that landed on the "other" replica. A `shared` + volumeset is one volume per location, mounted by every replica. + + `performanceClass: shared` is declared explicitly: omitting it makes the API + backfill exactly that value, which is permanent drift from creation. + + No `snapshots` block. The API accepts one, but shared volumes are expand-only + with no restore path we have verified — see "Backups" in the README rather than + a recovery procedure we cannot stand behind. +*/}} +kind: volumeset +name: {{ include "wordpress.volume.name" . }} +description: WordPress docroot — core, plugins, themes, uploads and wp-config.php +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "wordpress.tags" . | nindent 4 }} +spec: + fileSystemType: shared + initialCapacity: {{ .Values.volumeset.capacity }} + performanceClass: shared diff --git a/wordpress/versions/1.0.0/templates/workload.yaml b/wordpress/versions/1.0.0/templates/workload.yaml new file mode 100644 index 00000000..20ceedd5 --- /dev/null +++ b/wordpress/versions/1.0.0/templates/workload.yaml @@ -0,0 +1,151 @@ +{{- include "wordpress.validate" . }} +kind: workload +name: {{ include "wordpress.name" . }} +description: WordPress — Apache + mod_php serving the site and wp-admin +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "wordpress.tags" . | nindent 4 }} +spec: + # `standard`, not `stateful`: this tier is a stateless PHP server. All state is + # in the database and on the read-write-many docroot volume. (Measured: a + # standard workload MAY mount a `shared` volumeset — the stateful/vm + # restriction applies to block ext4/xfs volumesets only.) + type: standard + identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "wordpress.identity.name" . }} + containers: + - name: wordpress + image: {{ .Values.wordpress.image }} + # Boot goes through the mounted wrapper: it elects one replica to seed the + # shared docroot and create the admin account BEFORE Apache binds, then + # execs the image's own apache2-foreground. + command: /bin/bash + args: + - /cpln/start.sh + inheritEnv: false + minCpu: {{ .Values.wordpress.resources.minCpu | quote }} + minMemory: {{ .Values.wordpress.resources.minMemory | quote }} + cpu: {{ .Values.wordpress.resources.maxCpu | quote }} + memory: {{ .Values.wordpress.resources.maxMemory | quote }} + env: + # ── Database (mariadb subchart) ── + # wp-config-docker.php resolves these with getenv() on every request, so + # they stay platform-resolved secret references and are never + # interpolated into a string at render time. + - name: WORDPRESS_DB_HOST + value: {{ include "wordpress.mariadb.host" . }} + - name: WORDPRESS_DB_USER + value: 'cpln://secret/{{ include "wordpress.secret.db.name" . }}.username' + - name: WORDPRESS_DB_PASSWORD + value: 'cpln://secret/{{ include "wordpress.secret.db.name" . }}.password' + - name: WORDPRESS_DB_NAME + value: 'cpln://secret/{{ include "wordpress.secret.db.name" . }}.database' + - name: WORDPRESS_TABLE_PREFIX + value: {{ .Values.wordpress.tablePrefix | quote }} + # Any non-empty value is truthy to wp-config-docker.php, so `false` must + # render as an EMPTY string, never the word "false". + - name: WORDPRESS_DEBUG + value: {{ if .Values.wordpress.debug }}"1"{{ else }}""{{ end }} + # eval()-ed by wp-config.php on every request. + - name: WORDPRESS_CONFIG_EXTRA + value: |- + // The platform injects X-Forwarded-Proto on INTERNAL GVC hops too, not + // only at the public edge. Without this WordPress builds http:// URLs + // behind the TLS edge and redirect-loops. Deliberately not gated on + // publicAccess. + if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $_SERVER['HTTPS'] = 'on'; } + // WordPress decides it may write files by comparing getmyuid() with + // fileowner(). On a group-owned network mount that comparison fails and + // the admin is asked for FTP credentials on every plugin install. + define('FS_METHOD', 'direct'); + $cpln_site_url = getenv('WP_SITE_URL'); + if ($cpln_site_url) { define('WP_HOME', $cpln_site_url); define('WP_SITEURL', $cpln_site_url); } + # ── First-run bootstrap (read by /cpln/install.php only) ── + - name: WP_SITE_TITLE + value: {{ .Values.wordpress.siteTitle | quote }} + - name: WP_ADMIN_USERNAME + value: 'cpln://secret/{{ .Values.admin.secretName }}.username' + - name: WP_ADMIN_PASSWORD + value: 'cpln://secret/{{ .Values.admin.secretName }}.password' + - name: WP_ADMIN_EMAIL + value: 'cpln://secret/{{ .Values.admin.secretName }}.email' + ports: + - number: 80 + protocol: http + volumes: + - path: /var/www/html + recoveryPolicy: retain + uri: cpln://volumeset/{{ include "wordpress.volume.name" . }} + - path: /cpln/start.sh + recoveryPolicy: retain + uri: cpln://secret/{{ include "wordpress.secret.start.name" . }}.payload + - path: /cpln/install.php + recoveryPolicy: retain + uri: cpln://secret/{{ include "wordpress.secret.install.name" . }}.payload + - path: /usr/local/etc/php/conf.d/zz-cpln-wordpress.ini + recoveryPolicy: retain + uri: cpln://secret/{{ include "wordpress.secret.phpini.name" . }}.payload + # WordPress ships no health endpoint: / and /wp-login.php redirect before + # install and on host/scheme mismatch, and a static asset would prove Apache + # is up while PHP or the database is down. start.sh writes cpln-health.php + # into the docroot instead. + # + # readiness runs the DEEP check (PHP + database + is_blog_installed). + # Budget = 20 + 20*15 = 320 s, which has to cover seeding a 117 MB docroot + # onto network storage, the database wait and the install. + readinessProbe: + httpGet: + path: /cpln-health.php + port: 80 + scheme: HTTP + initialDelaySeconds: 20 + periodSeconds: 15 + failureThreshold: 20 + successThreshold: 1 + timeoutSeconds: 5 + # liveness runs the SHALLOW check, deliberately database-independent: a + # database blip must stop traffic to the replica (readiness) without + # restarting it in a loop. initialDelaySeconds is past the readiness budget + # so liveness never kills a container that is legitimately still seeding. + livenessProbe: + httpGet: + path: /cpln-health.php?shallow=1 + port: 80 + scheme: HTTP + initialDelaySeconds: 360 + periodSeconds: 30 + failureThreshold: 10 + successThreshold: 1 + timeoutSeconds: 5 + defaultOptions: + # Sent as a COMPLETE block: the API backfills PARTIAL blocks from its own + # platform defaults, not from this workload's settings. + autoscaling: + metric: disabled + minScale: {{ .Values.wordpress.replicas | int }} + maxScale: {{ .Values.wordpress.replicas | int }} + maxConcurrency: 0 + target: 95 + scaleToZeroDelay: 300 + capacityAI: false + debug: false + suspend: false + timeoutSeconds: 30 + # rolloutOptions is omitted ENTIRELY rather than partially: the API backfills a + # partial block, and maxUnavailableReplicas is silently dropped on some + # workload types, which would imply a rollout limit that is not in force. + firewallConfig: + external: + outboundAllowCIDR: + - 0.0.0.0/0 + {{- if .Values.publicAccess.enabled }} + inboundAllowCIDR: + - 0.0.0.0/0 + {{- else }} + inboundAllowCIDR: [] + {{- end }} + internal: + inboundAllowType: {{ .Values.internalAccess.type }} + {{- if eq .Values.internalAccess.type "workload-list" }} + inboundAllowWorkload: + {{- .Values.internalAccess.workloads | toYaml | nindent 8 }} + {{- include "wordpress.ownWorkloadLinks" . | nindent 8 }} + {{- end }} diff --git a/wordpress/versions/1.0.0/values.yaml b/wordpress/versions/1.0.0/values.yaml new file mode 100644 index 00000000..7b7be5cc --- /dev/null +++ b/wordpress/versions/1.0.0/values.yaml @@ -0,0 +1,83 @@ +# ─── WordPress ──────────────────────────────────────────────────────────────── +wordpress: + # Official image (library/wordpress); the tag pins WordPress AND PHP. + # It seeds the docroot on FIRST BOOT ONLY — after that WordPress owns its own + # files and you update core from the dashboard. Bumping this tag later changes + # PHP and Apache, not WordPress core. + image: wordpress:7.1.0-php8.4-apache + replicas: 1 # >=2 share one uploads volume and one database; see README "Availability" + siteTitle: My WordPress Site # set on first boot only; change later in Settings → General + siteUrl: "" # public base URL; empty = the auto-assigned *.cpln.app endpoint + tablePrefix: wp_ # change only when importing an existing WordPress database + debug: false # WP_DEBUG — PHP notices to the container log; leave off in production + resources: + minCpu: 250m + maxCpu: 1000m + minMemory: 512Mi + maxMemory: 1Gi + +# ─── PHP limits ─────────────────────────────────────────────────────────────── +# Stock php.ini caps uploads at 2M, which blocks most media uploads. +php: + uploadMaxSize: 64M # upload_max_filesize and post_max_size + memoryLimit: 256M # PHP memory_limit; some plugins want 256M in wp-admin + +# ─── Admin account (REQUIRED PREREQUISITE SECRET) ───────────────────────────── +# CREATE IT BEFORE YOU INSTALL. A `dictionary` secret with exactly three keys: +# `username`, `password`, `email`. Applied on FIRST BOOT ONLY — rotating it later +# does NOT change your login. If it does not exist the deployment WEDGES silently +# and `cpln logs` returns nothing; the only diagnostic is status.versions[].message +# from `cpln workload get-deployments`. +admin: + secretName: my-wordpress-admin + +# ─── Content storage ────────────────────────────────────────────────────────── +# One read-write-many volume per location, mounted at /var/www/html by every +# replica: core, plugins, themes, uploads and wp-config.php. Shared volumes can be +# expanded but NOT snapshotted — the media library has no backup, keep your own copy. +volumeset: + capacity: 10 # initial capacity in GiB (minimum is 10) + +# ─── Access ─────────────────────────────────────────────────────────────────── +# A firewall change takes ~30 s to a few minutes to propagate. +publicAccess: + enabled: true # HTTPS site on the auto-assigned *.cpln.app endpoint +internalAccess: + type: same-gvc # none | same-gvc | same-org | workload-list + workloads: [] # used only with workload-list + # workloads: + # - //gvc/GVC_NAME/workload/WORKLOAD_NAME + +# ─── MariaDB (subchart: mariadb) — posts, pages, users, settings ────────────── +# Every knob of the `mariadb` template is available under this key — including its +# native scheduled backups and phpMyAdmin console, with nothing extra to install: +# mariadb.backup.enabled: true +# mariadb.backup.provider: aws | gcp +# mariadb.backup.aws.bucket / .region / .cloudAccountName / .policyName +# mariadb.phpMyAdmin.enabled: true +# See "Backing up the bundled database" in the README. +mariadb: + image: mariadb:11 + # Credentials for the bundled database — internal plumbing no human types + # elsewhere. This chart CREATES both secrets named below out of these values, + # so there is nothing for you to create before installing. + credentials: + username: wordpress + password: change-me-wordpress-db # change before installing + database: wordpress + rootPassword: change-me-wordpress-db-root # change before installing + # Names of the two secrets this chart creates and the bundled MariaDB reads. + # Secret names are org-wide: give each wordpress release its own names. A second + # release left on these names is REFUSED at install (they are owned by the first + # release) — nothing is shared, overwritten or deleted. + credentialsSecretName: my-wordpress-db-credentials + rootPasswordSecretName: my-wordpress-db-root-password + resources: + minCpu: 150m # keeps cpu:minCpu under the stateful 4:1 cap (500/150 = 3.3:1) + maxCpu: 500m + minMemory: 256Mi + maxMemory: 1Gi + volumeset: + capacity: 10 # initial capacity in GiB (minimum is 10) + internalAccess: + type: same-gvc # WordPress must be able to reach the database From 5e259ce4f5ae83faccf8458e084b53ae96d93f22 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:53:50 -0600 Subject: [PATCH 2/5] wordpress: dedupe the internal workload list, correct the firewall propagation figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, no blockers. 1. templates/workload.yaml concatenated the user's internalAccess.workloads with this release's own workload links, so a user who listed the release's own workload (the natural thing to do, since the list is the only place that relationship is visible) got a duplicate array entry — a drift candidate against what the API stores. The own-workload links are now emitted first and a user-supplied link that matches one is skipped, matching calcom.internalFirewall and documenso's workload. Self-inclusion behaviour is unchanged; only the duplicate is gone. 2. The firewall propagation figure said "~30 seconds to several minutes" in the README and "~30 s to a few minutes" in values.yaml. The catalog's measured outer bound is 559 s (9m19s, cockroach), and "several minutes" invites exactly the "this knob is broken" conclusion the note exists to prevent. Both now read "~30 s to ~10 min", matching calcom, documenso and this template's own briefing, which already carried the correct figure. Verified: the default `helm template --set global.cpln.gvc=test-gvc` render is byte-identical to before (the dedupe only affects workload-list); a list naming the release's own workload renders it once, and the conditional own-workload entries (phpmyadmin, maria-backup) still appear when those subchart features are on; all six README YAML blocks are still byte-identical substrings of values.yaml; lint clean. --- wordpress/versions/1.0.0/README.md | 4 ++-- wordpress/versions/1.0.0/templates/workload.yaml | 14 +++++++++++++- wordpress/versions/1.0.0/values.yaml | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/wordpress/versions/1.0.0/README.md b/wordpress/versions/1.0.0/README.md index f054b0d9..b74c3317 100644 --- a/wordpress/versions/1.0.0/README.md +++ b/wordpress/versions/1.0.0/README.md @@ -99,7 +99,7 @@ volumeset: ```yaml # ─── Access ─────────────────────────────────────────────────────────────────── -# A firewall change takes ~30 s to a few minutes to propagate. +# A firewall change takes ~30 s to ~10 min to propagate. publicAccess: enabled: true # HTTPS site on the auto-assigned *.cpln.app endpoint internalAccess: @@ -200,7 +200,7 @@ WordPress's own "lost password" email cannot help here — see the note on outbo - **Install into a single-location GVC.** A fresh install refuses a multi-location GVC; an already-running site only warns, so the check can never take a live site down. - **`wordpress.siteTitle` and `wordpress.tablePrefix` apply on first boot only.** Change the title later in Settings → General; the table prefix cannot be changed after install. - **Give each release its own `mariadb.credentialsSecretName` and `mariadb.rootPasswordSecretName`.** Secret names are org-wide, and a second release left on the defaults is refused at install. -- **A firewall change takes ~30 seconds to several minutes to propagate.** After flipping `publicAccess.enabled` or `internalAccess.type`, keep re-polling rather than concluding the knob is broken. +- **A firewall change takes ~30 s to ~10 min to propagate.** After flipping `publicAccess.enabled` or `internalAccess.type`, keep re-polling rather than concluding the knob is broken. ## Links diff --git a/wordpress/versions/1.0.0/templates/workload.yaml b/wordpress/versions/1.0.0/templates/workload.yaml index 20ceedd5..90cbb4ab 100644 --- a/wordpress/versions/1.0.0/templates/workload.yaml +++ b/wordpress/versions/1.0.0/templates/workload.yaml @@ -145,7 +145,19 @@ spec: internal: inboundAllowType: {{ .Values.internalAccess.type }} {{- if eq .Values.internalAccess.type "workload-list" }} + {{- /* + This release's own workloads are merged in unconditionally: a + workload-list naming only the user's clients would otherwise deny the + release its own internal paths. A link the user also listed is emitted + ONCE — a duplicate array entry is a drift candidate. Same shape as + calcom.internalFirewall and documenso's workload. + */}} + {{- $own := splitList "\n" (trim (include "wordpress.ownWorkloadLinks" .)) }} inboundAllowWorkload: - {{- .Values.internalAccess.workloads | toYaml | nindent 8 }} {{- include "wordpress.ownWorkloadLinks" . | nindent 8 }} + {{- range .Values.internalAccess.workloads }} + {{- if not (has (printf "- %s" .) $own) }} + - {{ . }} + {{- end }} + {{- end }} {{- end }} diff --git a/wordpress/versions/1.0.0/values.yaml b/wordpress/versions/1.0.0/values.yaml index 7b7be5cc..3bffbc82 100644 --- a/wordpress/versions/1.0.0/values.yaml +++ b/wordpress/versions/1.0.0/values.yaml @@ -39,7 +39,7 @@ volumeset: capacity: 10 # initial capacity in GiB (minimum is 10) # ─── Access ─────────────────────────────────────────────────────────────────── -# A firewall change takes ~30 s to a few minutes to propagate. +# A firewall change takes ~30 s to ~10 min to propagate. publicAccess: enabled: true # HTTPS site on the auto-assigned *.cpln.app endpoint internalAccess: From a59dabfd9d6df6292bcbfe6aea8ed55ca41e6c30 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:57:22 -0600 Subject: [PATCH 3/5] wordpress: emit inboundAllowWorkload on every firewall arm The key was emitted only under `workload-list`, so the DEFAULT `same-gvc` install sent a PARTIAL `internal` block and the API completed it -- drift from creation, on the shape most users get. grafana-multi-location 2.0.0 shipped exactly this and it is worth recording why it survived review there: a `helm upgrade` drift gate CANNOT see this class. It reported `Unchanged` for both affected workloads the entire time the render and the stored spec disagreed, because the field is backfilled once at creation and then never churns. Only render-vs-stored finds it. Both arms verified: `same-gvc` now emits `[]`, and `workload-list` still emits this release's own workloads with user duplicates collapsed. Note the bundled mariadb subchart's own workload has the same gap. That is the `mariadb` template's defect and affects every chart vendoring it, so it is not fixed here. Co-Authored-By: Claude Opus 5 --- wordpress/versions/1.0.0/templates/workload.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/wordpress/versions/1.0.0/templates/workload.yaml b/wordpress/versions/1.0.0/templates/workload.yaml index 90cbb4ab..2a9c14ee 100644 --- a/wordpress/versions/1.0.0/templates/workload.yaml +++ b/wordpress/versions/1.0.0/templates/workload.yaml @@ -160,4 +160,15 @@ spec: - {{ . }} {{- end }} {{- end }} + {{- else }} + {{- /* + Emitted as [] on the non-list types too. Sending a PARTIAL `internal` + block makes the API complete it: grafana-multi-location 2.0.0 emitted + this key only under `workload-list`, and both its workloads drifted from + their own manifest from creation on the DEFAULT type. A `helm upgrade` + drift gate CANNOT see that class -- it reported `Unchanged` throughout, + because the field is backfilled once at creation and then never churns. + Only render-vs-stored finds it. + */}} + inboundAllowWorkload: [] {{- end }} From 8ef9901b2053e9fb6ecd56b9c664f10d23134817 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 12:43:01 -0600 Subject: [PATCH 4/5] wordpress 1.0.0: fix render-vs-stored drift, confine to one location, make the port-forward path work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test round 2026-08-31 (36 PASS / 2 FAIL) plus a coordinator finding. Four fixes, each against a measurement, not an inspection. 1. render-vs-stored FAIL (gate requires 0 differences; 15 measured, 4 ours). `templates/workload.yaml` sent a PARTIAL `firewallConfig.external` block, so the API completed it with `inboundBlockedCIDR`, `outboundBlockedCIDR` and `outboundAllowHostname` as `[]`, and an undeclared `supportDynamicTags` stored as `false` — four differences from creation on release test-wp. All four are now declared (shape copied from clickhouse 3.0.0). `outboundAllowPort` is deliberately still absent: the API did NOT backfill it, and declaring a field it does not store would create the drift this removes. The two no-op `helm upgrade`s were fully green throughout — this class is written once at creation and never churns, which is why both gates exist. The other 11 differences belong to the vendored `mariadb` 1.4.1 workload and cannot be fixed from a parent chart; recorded in the briefing instead. 2. No structural location confinement. The workload rendered a bare `minScale: {{ .Values.wordpress.replicas }}` with no `localOptions`, so it ran in EVERY location the GVC had and the boot-time guard was the only thing between a user and a second site — and the guard is best-effort (below). Adds a top-level `location` value with `defaultOptions.minScale/maxScale: 0` plus a `localOptions` entry carrying the real count (pgedge 2.0.0 / cockroach 2.0.0 / plane 1.0.0 shape), so an undeclared location starts nothing by construction. The bundled MariaDB still cannot be pinned by a parent and still runs everywhere — said plainly rather than claimed solved. 3. The guard is best-effort and now says so. It failed open on 1 boot in 5 (`WARNING: could not read GVC … guard SKIPPED`) and passed during a location-removal drain, which let aws-us-east-2 replicas seed and run a second complete independent WordPress install (east-1: 7 posts incl. the test post and its attachment; east-2: the bare 3-post fresh-install set). README, values comments and briefing now describe a safety net, not a guarantee. The guard is kept — it covers the unpinned MariaDB and the opposite direction — and it now logs the HTTP status, so a 403, a timeout and an unparseable 200 are no longer one identical line. Also drops the new `mktemp` dependency for `$$` paths. 4. The README's port-forward instruction stranded the user. Run verbatim at `publicAccess: false`: `/` → 301 to `http://localhost/`, `/wp-admin/` → 302 to an unresolvable `*.cpln.local`, `curl -L` → final=000; only `/cpln-health.php` answered. The tunnel was fine (200 through it while the canonical endpoint returned 403) — WordPress rewrites from its stored site URL. Now paired with `wordpress.siteUrl: http://localhost:8080` on the same port (measured: front page 200, login 302, wp-admin 200), with the port agreement made explicit. 5. The ~95–107 s first-upgrade outage is the bundled MariaDB, not the web tier. A WordPress-only roll was transparent at both replica counts (200/200 and 300/300, each with a control proving the replicas were replaced), so `wordpress.replicas` does not mitigate it. The Availability section said the opposite by implication; corrected. Verified without deploying: default render clean (14 resources, no other values); all four fields present on both publicAccess arms and the workload-list arm; localOptions carries exactly defaultOptions' field set plus `location`; the boot guard's branches exercised offline (1-loc ok / 2-loc fresh FATAL / 2-loc initialised warn / pinned-location mismatch / fail-open) and `bash -n` clean; 12 existing validation negatives and 4 positive controls unchanged; 3 new `location` negatives reject; every README YAML block byte-identical to values.yaml; template-lint clean. Needs a deploy to settle: whether the API stores `localOptions` verbatim (the render-vs-stored gate must be re-run), and the guard's HTTP-status arm against the real $CPLN_ENDPOINT — a local stub can only prove the parsing. Co-Authored-By: Claude Opus 5 --- briefings/wordpress.md | 70 +++++++++++++--- wordpress/versions/1.0.0/README.md | 53 ++++++++++-- .../versions/1.0.0/templates/_helpers.tpl | 21 +++++ .../1.0.0/templates/secret-start.yaml | 84 +++++++++++++++---- .../versions/1.0.0/templates/workload.yaml | 52 +++++++++++- wordpress/versions/1.0.0/values.yaml | 9 ++ 6 files changed, 248 insertions(+), 41 deletions(-) diff --git a/briefings/wordpress.md b/briefings/wordpress.md index 6dcacbc8..fb742877 100644 --- a/briefings/wordpress.md +++ b/briefings/wordpress.md @@ -21,12 +21,12 @@ | Resource | Purpose | |---|---| -| `{release}-wordpress` workload (`standard`) | Apache + mod_php + WordPress on :80 | +| `{release}-wordpress` workload (`standard`) | Apache + mod_php + WordPress on :80, **pinned to the single `location`** | | `{release}-wordpress-vs` volumeset (**`shared`**, RWX) | `/var/www/html` — core, plugins, themes, uploads, `wp-config.php` | | `{release}-wordpress-start` / `-install` / `-php-ini` secrets | boot wrapper, first-run installer, PHP limits | | `{release}-wordpress-identity` + 2 policies | `reveal` on its 5 secrets; `view` on the one GVC (boot location guard) | | DB credentials (dictionary) + root password (opaque) secrets | created by **this** chart, read by the MariaDB subchart | -| `{release}-maria` workload + `{release}-maria-vs` (`ext4`) | bundled MariaDB 11 (subchart `mariadb` 1.4.1) | +| `{release}-maria` workload + `{release}-maria-vs` (`ext4`) | bundled MariaDB 11 (subchart `mariadb` 1.4.1) — **not** pinned; a parent cannot template a subchart's workload | - The volumeset is **`shared` (read-write-many), not block** — that is the whole reason multi-replica is possible: a block volumeset gives each replica its **own** volume, so two @@ -38,8 +38,9 @@ | Knob | Default | Note | |---|---|---| +| `location` | `aws-us-east-1` | the ONE GVC location WordPress runs in; a location the GVC lacks starts nothing, silently | | `wordpress.image` | `wordpress:7.1.0-php8.4-apache` | seeds the docroot on **first boot only** | -| `wordpress.replicas` | `1` | ≥2 = redundant web tier; database is still single | +| `wordpress.replicas` | `1` | ≥2 survives a replica loss; does NOT help with the MariaDB bounce below | | `wordpress.siteUrl` | `""` | empty = the auto-assigned `*.cpln.app` URL | | `wordpress.siteTitle` / `tablePrefix` / `debug` | `My WordPress Site` / `wp_` / `false` | title and prefix are first-boot only | | `php.uploadMaxSize` / `php.memoryLimit` | `64M` / `256M` | stock php.ini caps uploads at 2M | @@ -71,12 +72,29 @@ - **Outbound email does not work out of the box.** The container has no mail transport, so `wp_mail()` — including password reset and invites — fails silently until an SMTP plugin is installed. Most likely "WordPress is broken" report. -- **This template expects a single-location GVC.** On a two-location GVC the platform would run - one WordPress *and* one MariaDB per location, each with its own volume — silent data - divergence. The container reads its own GVC at boot and **hard-fails on a fresh install**, - naming the locations; on an already-initialised docroot it only warns, so the check can never - take a live site down. If the GVC read fails (e.g. the GVC policy was removed) it warns and - continues — a control-plane hiccup must not stop a website booting. +- **Single location, TWO layers, only one of which is a guarantee.** A workload runs in every + location its GVC has, so a second location means a second everything. + - **Structural (holds by construction):** the WordPress workload sets `defaultOptions` + `minScale/maxScale: 0` and supplies the real count via a `localOptions` entry for + `.Values.location`. An undeclared location gets `desiredScale: 0` and reports `This workload + location is deactivated because maxScale is set to 0`. + - **Runtime (best-effort):** the boot-time GVC read still refuses a **fresh** docroot on a + multi-location GVC and only warns on an initialised one. It is needed because the **bundled + MariaDB cannot be pinned** — a parent cannot template a subchart's workload spec, so MariaDB + still runs in every location with its own volume and its own database behind one DNS name. + - **Say "best-effort", never "guaranteed", about the runtime layer.** Measured 2026-08-31: it + **failed open on 1 boot in 5** (`WARNING: could not read GVC … guard SKIPPED`, most likely the + very start of a replica's life or the ~4-minute authorization cache window), and it **passed + during a location-removal drain**, which let `aws-us-east-2` replicas seed and run a **second + complete independent WordPress install**. The split it prevents is total and measured: + `aws-us-east-1` held 7 posts including the test post and its attachment, `aws-us-east-2` held + the bare 3-post fresh-install set. **Never change a GVC's locations under a live release.** + - The guard now logs the **HTTP status** (`403` / `000` / `200`-but-unparseable) — previously a + 403, a timeout and a bad body all produced one identical line and a maintainer had nothing to + go on. + - Residual hole with no in-container fix: a `location` the GVC LACKS is accepted, stored and + inert, so nothing boots and no check can fire. Diagnose with `get-deployments` showing no + replicas. - **Cold start elects one replica.** With several replicas on one RWX docroot, a naive cold start would have every replica seed and install at once, so `start.sh` takes an atomic `mkdir` lock; the loser waits for a done-marker (bounded, then releases a stale lock and exits so the @@ -87,9 +105,21 @@ undefined there. Harmless for the documented password reset, but do not diagnose the site URL that way — check it over HTTP instead. - **The first `helm upgrade` after an install may bounce the bundled MariaDB** (probabilistic - across the catalog). At `replicas: 1` the site returns errors for a minute or two while the - database comes back. Check whether the database is restarting before diagnosing a credential - problem. + across the catalog) — measured at **95–107 s of site outage** (503 → WordPress's own + `Database Error` page at 500 → 200). **It is the DATABASE, not the web tier, and + `wordpress.replicas` does not mitigate it:** a WordPress-only roll was transparent at both + replica counts, 200/200 and 300/300, each with a control proving the replicas were genuinely + replaced. The platform surges a `standard` workload even at `maxScale: 1`. Check whether the + database is restarting before diagnosing a credential problem. During the bounce + `/cpln-health.php` returns 503 while `?shallow=1` returns 200 — the deep/shallow split working + as designed. +- **A private install needs `wordpress.siteUrl` to match the port-forward address.** WordPress + rewrites every URL from its stored site URL, so `port-forward` alone against the default + `siteUrl` gives `301 → http://localhost/` and `302 →` an unresolvable `*.cpln.local`; only + `/cpln-health.php` answers. The tunnel is fine (health returned 200 through it while the + canonical endpoint returned 403). Pair it with `siteUrl: http://localhost:8080` on the same + port — measured working: front page 200, login 302, wp-admin 200. Set `siteUrl` back before + going public, or visitors get redirected to `localhost`. - **Availability posture, plainly:** `replicas: 2` removes the web tier as a single point of failure — replicas are independent PHP servers sharing one database and one filesystem, with cookie-based auth signed by salts in the shared `wp-config.php`, so no session affinity or peer @@ -114,3 +144,19 @@ `scaleToZeroDelay: 300`, on a partial `defaultOptions.autoscaling` block. Omitting `rolloutOptions` entirely stored no `rolloutOptions` at all. - The image has `curl` but **no `wget`**, and `/usr/src/wordpress` is 117 MB (not ~250 MB). +- **A partial `firewallConfig.external` block is COMPLETED by the API.** Sending only + `inbound/outboundAllowCIDR` had it backfill `inboundBlockedCIDR`, `outboundBlockedCIDR` and + `outboundAllowHostname` as `[]`, and an undeclared `supportDynamicTags` stored as `false` — + four permanent rendered-vs-stored differences from creation, now declared explicitly. + `outboundAllowPort` was **not** backfilled, so it is deliberately still absent: declaring a + field the API does not store creates the drift it was meant to remove. +- **A `helm upgrade` drift gate cannot see that class.** Two no-op upgrades were fully green + (14/14 `Unchanged` on the second) while four fields differed from creation, because they are + written once and never churn. Only the render-vs-stored gate finds it — both gates are needed. +- **The bundled `mariadb` 1.4.1 subchart contributes 11 more render-vs-stored differences** and + they are NOT fixable from here: a parent cannot template a subchart's workload spec. They are a + partial `defaultOptions` (`maxConcurrency`, `scaleToZeroDelay`, `target`, `debug`, `suspend`), a + partial `firewallConfig.external` (the same three lists plus `inboundAllowCIDR`), a missing + `inboundAllowWorkload` on the non-list branch, and a missing `supportDynamicTags`. Every + `mariadb` install in the catalog carries this drift, and every parent that vendors it inherits + it — worth a tracking issue against the `mariadb` template, not against this one. diff --git a/wordpress/versions/1.0.0/README.md b/wordpress/versions/1.0.0/README.md index b74c3317..3a40789b 100644 --- a/wordpress/versions/1.0.0/README.md +++ b/wordpress/versions/1.0.0/README.md @@ -4,9 +4,9 @@ ## Architecture -- **WordPress workload** (`standard`) — Apache + mod_php serving the site and `wp-admin` on port 80. -- **Docroot volumeset** (`shared`, read-write-many) — `/var/www/html`: WordPress core, plugins, themes, uploads and the generated `wp-config.php`. One volume per location, mounted by every replica. -- **MariaDB workload + volumeset** (subchart `mariadb`, `stateful`) — posts, pages, users and settings. +- **WordPress workload** (`standard`) — Apache + mod_php serving the site and `wp-admin` on port 80, pinned to the single `location`. +- **Docroot volumeset** (`shared`, read-write-many) — `/var/www/html`: WordPress core, plugins, themes, uploads and the generated `wp-config.php`. One volume in `location`, mounted by every replica. +- **MariaDB workload + volumeset** (subchart `mariadb`, `stateful`) — posts, pages, users and settings. Not pinned: it runs in every location the GVC has. - **Three chart secrets** — the boot wrapper, the first-run installer, and a PHP limits overlay. - **Two chart-created database secrets** — the bundled MariaDB's application credentials and its root password, kept separate so the application credential never carries root. - **Identity + two policies** — `reveal` on exactly the five secrets the container reads, and `view` on the single install GVC for the boot-time location guard. @@ -29,12 +29,33 @@ cpln workload get-deployments RELEASE-wordpress --gvc YOUR-GVC -o yaml ``` - Create the secret and the deployment recovers on its own within roughly 5–10 minutes, or immediately with `cpln workload force-redeployment RELEASE-wordpress --gvc YOUR-GVC`. + Create the secret and the deployment recovers on its own within roughly 5–10 minutes, or immediately with `cpln workload force-redeployment RELEASE-wordpress --gvc YOUR-GVC`. Allow about three more minutes after that for the first-boot seed — from creating the secret late to a working site is roughly 11 minutes. -- **A single-location GVC.** In a multi-location GVC the platform would run one WordPress *and* one MariaDB per location, each with its own volume — the media library and the database would split silently. The container checks this at boot and refuses a fresh install on a multi-location GVC. +- **A single-location GVC**, and `location` set to that location. Every workload runs in *every* location its GVC has, so a second location would mean a second WordPress and a second MariaDB, each on its own volume, behind one hostname. Measured: one location held the live site's 7 posts and its uploads while the other held nothing but the bare 3-post fresh-install set, with every status surface green. + + Two things guard that, and only one of them is a guarantee: + + - **WordPress itself is pinned** to `location` (`maxScale: 0` everywhere else), so no other location of the GVC can start one. That holds by construction. + - **The bundled MariaDB is not pinned** — it comes from the `mariadb` subchart, whose workload spec this chart cannot template, so it still starts in every location the GVC has. The container reads the GVC at boot and refuses a *fresh* install when it sees more than one location. Treat that as a **safety net, not a guarantee**: it fails open when the GVC cannot be read (observed on 1 boot in 5, and on any boot inside the ~4-minute window after an authorization change), and it passes during a location add/remove transition. + + **Do not change a GVC's locations under a live WordPress release.** During a location *removal*, replicas in the departing location read the GVC as single-location, passed the check, and installed a second, completely independent WordPress site. ## Configuration +### Location + +```yaml +# ─── Location ───────────────────────────────────────────────────────────────── +# The ONE location of your GVC that WordPress runs in. Every replica is pinned +# here, and no other location of the GVC starts one: an undeclared location gets +# maxScale 0 and starts nothing. Without that pin an extra GVC location silently +# runs a SECOND, complete, independent WordPress on its own volume. +# It must be a location your GVC actually has — the platform accepts one the GVC +# lacks without any error and then runs nothing, with no failed deployment to see. +location: aws-us-east-1 + +``` + ### WordPress ```yaml @@ -159,17 +180,30 @@ mariadb: | Health endpoint | `/cpln-health.php` — returns `ok` when PHP, the database and the install are all good | | Database (internal only) | `RELEASE-maria.YOUR-GVC.cpln.local:3306`, credentials in the `mariadb.credentialsSecretName` secret | -With `publicAccess.enabled: false` the site is still reachable in a browser through a tunnel: +With `publicAccess.enabled: false` the site is still reachable in a browser through a tunnel — **but the tunnel address and `wordpress.siteUrl` have to agree.** WordPress rewrites every URL from its stored site URL, so a tunnel opened while `siteUrl` is empty bounces the browser somewhere it cannot reach (`/` redirects to `http://localhost/`, `/wp-admin/` to the internal `*.cpln.local` name) and only `/cpln-health.php` answers. Point the site URL at the tunnel, on the same port: ```bash +# 1. in your values — the site URL is the address you will actually browse: +# publicAccess: +# enabled: false +# wordpress: +# siteUrl: http://localhost:8080 +# +# 2. open the tunnel on the SAME port (a mismatch reproduces the redirect): cpln port-forward RELEASE-wordpress 8080:80 --gvc YOUR-GVC +# +# 3. browse http://localhost:8080/ and http://localhost:8080/wp-admin/ ``` +`wordpress.siteUrl` takes effect on an already-installed site, so this works as an upgrade rather than a reinstall. Set it back to `""` (or to your real public URL) before turning `publicAccess.enabled` on again, otherwise the public site redirects your visitors to `localhost`. + ## Availability -`wordpress.replicas: 2` or more removes the web tier as a single point of failure. Replicas are independent PHP servers sharing one database and one filesystem; authentication is cookie-based and signed with the salts in the shared `wp-config.php`, so any replica accepts any other's cookie and no session affinity is needed. +`wordpress.replicas: 2` or more removes the web tier as a single point of failure: losing a replica was absorbed with zero failed requests. Replicas are independent PHP servers sharing one database and one filesystem; authentication is cookie-based and signed with the salts in the shared `wp-config.php`, so any replica accepts any other's cookie and no session affinity is needed. All replicas run in the single `location` — this is not a multi-region deployment. + +**A rolling restart is already transparent at `replicas: 1`.** The platform surges a new replica before retiring the old one, so an upgrade that touches only WordPress serves without interruption at any replica count. -**The bundled MariaDB stays single-instance and is the availability ceiling.** With more replicas the web tier survives a replica loss and a rolling restart; the database does not. There is no HA MariaDB template to depend on yet. +**The bundled MariaDB stays single-instance and is the availability ceiling** — and it is where the outage you are most likely to actually see comes from. The first `helm upgrade` after an install may bounce the bundled database, and the site then returns errors for roughly 95–107 seconds while it comes back. **More `wordpress.replicas` does not help with that**: every replica depends on the one database. Later upgrades do not repeat it. There is no HA MariaDB template to depend on yet. ## Backups @@ -197,7 +231,8 @@ WordPress's own "lost password" email cannot help here — see the note on outbo - **Change `mariadb.credentials.password` and `mariadb.credentials.rootPassword` before installing.** They ship as obviously-invalid `change-me-…` placeholders and are used as-is. - **Bumping `wordpress.image` does NOT upgrade WordPress core on an existing install.** The image seeds the docroot only while it is empty; after that WordPress owns its files and you update core from the dashboard. A tag bump changes PHP and Apache only. - **Outbound email does not work out of the box.** The container has no mail transport, so `wp_mail()` — including password reset and user invites — fails silently. Install an SMTP plugin before you rely on any email. -- **Install into a single-location GVC.** A fresh install refuses a multi-location GVC; an already-running site only warns, so the check can never take a live site down. +- **Install into a single-location GVC, set `location` to that location, and do not change the GVC's locations afterwards.** WordPress is pinned to `location`, but the bundled MariaDB is a subchart workload this chart cannot pin and still runs in every location. The boot-time check that catches that is a best-effort safety net, not a guarantee: it fails open if it cannot read the GVC and does not hold during a location add/remove. Data split across locations cannot be merged back. +- **A `location` your GVC does not have starts nothing, silently.** The platform accepts it without any error and there is no failed deployment to see — the workload simply has no replicas. Check `cpln workload get-deployments RELEASE-wordpress` if an install appears to do nothing. - **`wordpress.siteTitle` and `wordpress.tablePrefix` apply on first boot only.** Change the title later in Settings → General; the table prefix cannot be changed after install. - **Give each release its own `mariadb.credentialsSecretName` and `mariadb.rootPasswordSecretName`.** Secret names are org-wide, and a second release left on the defaults is refused at install. - **A firewall change takes ~30 s to ~10 min to propagate.** After flipping `publicAccess.enabled` or `internalAccess.type`, keep re-polling rather than concluding the knob is broken. diff --git a/wordpress/versions/1.0.0/templates/_helpers.tpl b/wordpress/versions/1.0.0/templates/_helpers.tpl index 1b6a6d4d..33bd7ed3 100644 --- a/wordpress/versions/1.0.0/templates/_helpers.tpl +++ b/wordpress/versions/1.0.0/templates/_helpers.tpl @@ -126,6 +126,27 @@ because a hand-maintained list is exactly what drifts when that changes. {{- fail "wordpress: mariadb.credentialsSecretName and mariadb.rootPasswordSecretName must be DIFFERENT secrets — the application credential is deliberately separate from root, so sharing the first does not hand out the second" -}} {{- end -}} +{{- /* + Location — the ONE GVC location this release runs in. It is what CONFINES the + WordPress workload: `defaultOptions` scales to 0 everywhere and `localOptions` + supplies the real count here. The platform does NOT validate this direction — + a localOptions entry naming a location the GVC lacks is accepted, stored, and + simply inert — so the render-time checks below catch the shapes we can see and + the boot-time GVC read in start.sh catches the rest, best-effort. +*/ -}} +{{- if hasKey .Values "locations" -}} +{{- fail "wordpress: `locations` (plural) is not a key of this chart. WordPress runs in exactly ONE location — one docroot volume and one bundled database — so use the singular `location`, e.g. `location: aws-us-east-1`" -}} +{{- end -}} +{{- if not .Values.location -}} +{{- fail "wordpress: `location` is required — it names the ONE location of your GVC that WordPress runs in, e.g. `location: aws-us-east-1`. Every replica is pinned there and no other location of the GVC starts one" -}} +{{- end -}} +{{- if not (kindIs "string" .Values.location) -}} +{{- fail "wordpress: `location` must be a single location NAME, e.g. `location: aws-us-east-1`. WordPress runs in exactly one location: the docroot is one volume and the bundled database is one instance" -}} +{{- end -}} +{{- if or (hasPrefix "/" .Values.location) (contains "/" .Values.location) -}} +{{- fail (printf "wordpress: `location` must be a bare location NAME, not a link — use `aws-us-east-1`, not '%s'" .Values.location) -}} +{{- end -}} + {{- /* Replicas. */ -}} {{- if lt (int .Values.wordpress.replicas) 1 -}} {{- fail (printf "wordpress: wordpress.replicas must be at least 1, got '%v'" .Values.wordpress.replicas) -}} diff --git a/wordpress/versions/1.0.0/templates/secret-start.yaml b/wordpress/versions/1.0.0/templates/secret-start.yaml index d2fe463d..05fc814c 100644 --- a/wordpress/versions/1.0.0/templates/secret-start.yaml +++ b/wordpress/versions/1.0.0/templates/secret-start.yaml @@ -42,52 +42,104 @@ data: {{- end }} # ── 1. Single-location GVC guard ───────────────────────────────────────────── - # On a multi-location GVC the platform starts this workload AND the bundled - # MariaDB in every location: two databases with two independent volumes behind - # one DNS name, plus two divergent upload volumes. That is silent data - # divergence, so a FRESH install refuses. An already-initialised docroot only - # warns -- this check must never take a live site down. + # SECOND layer, not the only one. The workload itself is CONFINED to the one + # configured location by defaultOptions maxScale 0 + localOptions, so an extra + # GVC location can no longer start a second WordPress. What confinement does + # NOT cover is the bundled MariaDB: a subchart's workload spec cannot be + # templated by the parent, so MariaDB still runs in EVERY location the GVC has, + # each with its own volume and its own database, behind one DNS name. That is + # what this check is for now. + # + # It is BEST-EFFORT and must be described that way everywhere: it fails open + # when the GVC cannot be read (measured on 1 boot in 5, and on any boot inside + # the ~4-minute authorization cache window after a policy change), and a + # location add/remove transition can make a multi-location GVC read as one. + # A FRESH docroot refuses; an already-initialised one only warns, so the check + # can never take a live site down. FRESH=no if [ ! -e wp-includes/version.php ] && [ ! -e index.php ]; then FRESH=yes; fi + CONFIGURED_LOCATION="{{ .Values.location }}" + THIS_LOCATION="$(basename "${CPLN_LOCATION:-unknown}")" + LOCATIONS="" + GVC_HTTP="000" + GVC_ERR="" if [ -n "${WP_GVC_LOCATIONS_OVERRIDE:-}" ]; then # Test hook: lets the >1-location disposition be exercised on a # single-location GVC. The chart never sets this. LOCATIONS="$WP_GVC_LOCATIONS_OVERRIDE" + GVC_HTTP="override" log "GVC location list overridden for testing: $LOCATIONS" elif [ -n "${CPLN_TOKEN:-}" ] && [ -n "${CPLN_ORG:-}" ] && [ -n "${CPLN_GVC:-}" ]; then # curl defaults to HTTP/1.1; $CPLN_ENDPOINT sits behind istio-envoy, which # answers HTTP/1.0 with 426. --max-time bounds the whole call, not just connect. - GVC_JSON="$(curl -sS --connect-timeout 5 --max-time 15 --retry 2 --retry-delay 3 \ + # + # The status code is captured and logged: a 403 (no `view` on the GVC), a + # timeout and an unparseable 200 are three different problems, and this + # script used to render all three as one identical WARNING line -- which was + # the only diagnostic a maintainer got. + # Fixed $$-suffixed paths rather than mktemp: every other external command + # this script uses is already proven present in the pinned image, and this + # avoids adding one more that is only exercised at boot. + GVC_BODY="/tmp/cpln-gvc-body.$$"; GVC_ERRF="/tmp/cpln-gvc-err.$$" + GVC_HTTP="$(curl -sS -o "$GVC_BODY" -w '%{http_code}' \ + --connect-timeout 5 --max-time 15 --retry 2 --retry-delay 3 \ -H "Authorization: $CPLN_TOKEN" \ - "${CPLN_ENDPOINT:-http://api.cpln.io}/org/$CPLN_ORG/gvc/$CPLN_GVC" 2>/dev/null || true)" - if [ -n "$GVC_JSON" ]; then - LOCATIONS="$(printf '%s' "$GVC_JSON" | php -r ' + "${CPLN_ENDPOINT:-http://api.cpln.io}/org/$CPLN_ORG/gvc/$CPLN_GVC" 2>"$GVC_ERRF" || echo "000")" + GVC_ERR="$(tr '\n' ' ' < "$GVC_ERRF" | cut -c1-200)" + if [ "$GVC_HTTP" = "200" ]; then + LOCATIONS="$(php -r ' $d = json_decode(stream_get_contents(STDIN), true); $l = $d["spec"]["staticPlacement"]["locationLinks"] ?? null; if (!is_array($l) || !count($l)) { exit(1); } echo implode(",", array_map(function ($x) { return basename($x); }, $l)); - ' 2>/dev/null || true)" + ' < "$GVC_BODY" 2>/dev/null || true)" fi + rm -f "$GVC_BODY" "$GVC_ERRF" + else + GVC_HTTP="no-env" + GVC_ERR="CPLN_TOKEN, CPLN_ORG or CPLN_GVC missing from the environment" fi if [ -z "$LOCATIONS" ]; then # A control-plane hiccup or a missing GVC policy must not stop a site booting. - log "WARNING: could not read GVC '${CPLN_GVC:-?}' - single-location guard SKIPPED" + log "WARNING: could not read GVC '${CPLN_GVC:-?}' (HTTP $GVC_HTTP)${GVC_ERR:+ - $GVC_ERR}" + case "$GVC_HTTP" in + 403) log "WARNING: 403 = this workload's identity has no 'view' on the GVC; check this chart's GVC policy" ;; + 200) log "WARNING: the GVC read succeeded but spec.staticPlacement.locationLinks could not be parsed" ;; + 000) log "WARNING: no HTTP response (connect/timeout); this is common on the first seconds of a replica's life" ;; + esac + log "WARNING: GVC location check SKIPPED - it is a best-effort safety net, not a guarantee" else LOCATION_COUNT="$(printf '%s' "$LOCATIONS" | awk -F, '{print NF}')" + + # Opposite direction: the platform accepts a localOptions entry naming a + # location the GVC LACKS, stores it, and runs nothing -- with no failed + # deployment to observe. Warn only: reaching this line at all means a replica + # DID start, so the pin cannot be entirely wrong; and refusing here would + # take down a site over a control-plane reading. + case ",$LOCATIONS," in + *",$CONFIGURED_LOCATION,"*) : ;; + *) log "WARNING: this release is pinned to location '$CONFIGURED_LOCATION', which GVC '${CPLN_GVC:-?}' does not list ($LOCATIONS). Set 'location' to one of the GVC's own locations." ;; + esac + if [ "$THIS_LOCATION" != "unknown" ] && [ "$THIS_LOCATION" != "$CONFIGURED_LOCATION" ]; then + log "WARNING: this replica is running in '$THIS_LOCATION' but the release is configured for '$CONFIGURED_LOCATION'" + fi + if [ "$LOCATION_COUNT" -gt 1 ]; then if [ "$FRESH" = yes ]; then log "FATAL: GVC '${CPLN_GVC:-?}' has $LOCATION_COUNT locations ($LOCATIONS)." - log "FATAL: This template supports a SINGLE-location GVC only. In a multi-location" - log "FATAL: GVC the platform runs one WordPress AND one MariaDB per location, each" - log "FATAL: with its own volume - the media library and the database silently split." + log "FATAL: This template supports a SINGLE-location GVC only. WordPress itself is" + log "FATAL: pinned to '$CONFIGURED_LOCATION', but the bundled MariaDB is a subchart" + log "FATAL: workload this chart cannot pin - it runs in EVERY location, each with its" + log "FATAL: own volume and its own database, behind one DNS name." log "FATAL: Install into a single-location GVC instead." exit 1 fi - log "WARNING: GVC '${CPLN_GVC:-?}' has $LOCATION_COUNT locations ($LOCATIONS) - this site's" - log "WARNING: data may be split per location. Not failing: the docroot is already initialised." + log "WARNING: GVC '${CPLN_GVC:-?}' has $LOCATION_COUNT locations ($LOCATIONS) - the bundled" + log "WARNING: MariaDB runs in each of them with its own database. Not failing: the docroot" + log "WARNING: is already initialised, and this check must never take a live site down." else log "single-location GVC ok ($LOCATIONS)" fi diff --git a/wordpress/versions/1.0.0/templates/workload.yaml b/wordpress/versions/1.0.0/templates/workload.yaml index 2a9c14ee..d2fed756 100644 --- a/wordpress/versions/1.0.0/templates/workload.yaml +++ b/wordpress/versions/1.0.0/templates/workload.yaml @@ -118,10 +118,20 @@ spec: defaultOptions: # Sent as a COMPLETE block: the API backfills PARTIAL blocks from its own # platform defaults, not from this workload's settings. + # + # minScale/maxScale 0 is the STRUCTURAL half of the single-location rule, and + # it is the half that holds by construction. A workload runs in EVERY location + # its GVC has, so without this an extra GVC location starts a second, complete, + # independent WordPress on its own volume -- measured on 2026-08-31, where a + # second location seeded its own docroot, ran its own installer and created its + # own admin user while every status surface stayed green. `localOptions` below + # supplies the real count for the ONE configured location; any other location + # gets desiredScale 0 and reports `This workload location is deactivated + # because maxScale is set to 0`. autoscaling: metric: disabled - minScale: {{ .Values.wordpress.replicas | int }} - maxScale: {{ .Values.wordpress.replicas | int }} + minScale: 0 + maxScale: 0 maxConcurrency: 0 target: 95 scaleToZeroDelay: 300 @@ -129,19 +139,50 @@ spec: debug: false suspend: false timeoutSeconds: 30 + localOptions: + # A localOptions entry is a FULL override, not a patch: every field + # defaultOptions carries is repeated here, because the API backfills its OWN + # platform defaults for anything left out -- not this workload's defaults. + - autoscaling: + metric: disabled + minScale: {{ .Values.wordpress.replicas | int }} + maxScale: {{ .Values.wordpress.replicas | int }} + maxConcurrency: 0 + target: 95 + scaleToZeroDelay: 300 + capacityAI: false + debug: false + location: //location/{{ .Values.location }} + suspend: false + timeoutSeconds: 30 # rolloutOptions is omitted ENTIRELY rather than partially: the API backfills a # partial block, and maxUnavailableReplicas is silently dropped on some # workload types, which would imply a rollout limit that is not in force. firewallConfig: external: - outboundAllowCIDR: - - 0.0.0.0/0 {{- if .Values.publicAccess.enabled }} inboundAllowCIDR: - 0.0.0.0/0 {{- else }} inboundAllowCIDR: [] {{- end }} + {{- /* + The three empty lists below are NOT decoration. The API COMPLETES a + PARTIAL block from its own defaults -- send the whole block or none of + it. Sending only inbound/outboundAllowCIDR made the API backfill + inboundBlockedCIDR, outboundBlockedCIDR and outboundAllowHostname, so + the stored spec disagreed with the rendered manifest from creation + (measured 2026-08-31 on release test-wp). A `helm upgrade` drift gate + CANNOT see this class -- it was fully green -- because the fields are + written once at creation and then never churn. `outboundAllowPort` is + deliberately absent: the API did NOT backfill it, and declaring a field + the API does not store would create the drift this block removes. + */}} + inboundBlockedCIDR: [] + outboundAllowCIDR: + - 0.0.0.0/0 + outboundAllowHostname: [] + outboundBlockedCIDR: [] internal: inboundAllowType: {{ .Values.internalAccess.type }} {{- if eq .Values.internalAccess.type "workload-list" }} @@ -172,3 +213,6 @@ spec: */}} inboundAllowWorkload: [] {{- end }} + # Declared rather than left to the API, which stores `false` either way: an + # undeclared field is a permanent rendered-vs-stored difference. + supportDynamicTags: false diff --git a/wordpress/versions/1.0.0/values.yaml b/wordpress/versions/1.0.0/values.yaml index 3bffbc82..be13f060 100644 --- a/wordpress/versions/1.0.0/values.yaml +++ b/wordpress/versions/1.0.0/values.yaml @@ -1,3 +1,12 @@ +# ─── Location ───────────────────────────────────────────────────────────────── +# The ONE location of your GVC that WordPress runs in. Every replica is pinned +# here, and no other location of the GVC starts one: an undeclared location gets +# maxScale 0 and starts nothing. Without that pin an extra GVC location silently +# runs a SECOND, complete, independent WordPress on its own volume. +# It must be a location your GVC actually has — the platform accepts one the GVC +# lacks without any error and then runs nothing, with no failed deployment to see. +location: aws-us-east-1 + # ─── WordPress ──────────────────────────────────────────────────────────────── wordpress: # Official image (library/wordpress); the tag pins WordPress AND PHP. From 698b365f9d1edd3699e679e2b827c2142b98cf36 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 19:09:43 -0600 Subject: [PATCH 5/5] wordpress: widen the location check to a shape regex The link-form check caught '//location/x' but not a comma-separated list ('aws-us-east-1,aws-us-east-2'), which Helm and the API both accept and which stores a placement matching no real location -- nothing runs anywhere and the logs are silent. Found on documenso in round 2; the same class applied here. One regex closes every arm: link form, comma list, uppercase, whitespace. --- wordpress/versions/1.0.0/templates/_helpers.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wordpress/versions/1.0.0/templates/_helpers.tpl b/wordpress/versions/1.0.0/templates/_helpers.tpl index 33bd7ed3..2c95f409 100644 --- a/wordpress/versions/1.0.0/templates/_helpers.tpl +++ b/wordpress/versions/1.0.0/templates/_helpers.tpl @@ -143,8 +143,8 @@ because a hand-maintained list is exactly what drifts when that changes. {{- if not (kindIs "string" .Values.location) -}} {{- fail "wordpress: `location` must be a single location NAME, e.g. `location: aws-us-east-1`. WordPress runs in exactly one location: the docroot is one volume and the bundled database is one instance" -}} {{- end -}} -{{- if or (hasPrefix "/" .Values.location) (contains "/" .Values.location) -}} -{{- fail (printf "wordpress: `location` must be a bare location NAME, not a link — use `aws-us-east-1`, not '%s'" .Values.location) -}} +{{- if not (regexMatch "^[a-z0-9]+(-[a-z0-9]+)*$" .Values.location) -}} +{{- fail (printf "wordpress: `location` must be a bare location NAME like `aws-us-east-1`, got '%s'. A link form (`//location/x`, which is what `spec.staticPlacement.locationLinks` contains) or a comma-separated list is accepted by Helm AND by the API, and stores a placement that matches no real location — so WordPress starts NOWHERE, every location reports `deactivated because maxScale is set to 0`, and the logs are silent." .Values.location) -}} {{- end -}} {{- /* Replicas. */ -}}