feat(bp-wordpress-tenant): turnkey SSO-wired WordPress per SME (#800) (#811)

New scratch Blueprint chart `bp-wordpress-tenant` v0.1.0 that
provisions a turnkey, SSO-pre-wired WordPress instance per SME tenant
inside the SME's vcluster, satisfying ticket #800 (SME-5) of the #795
SME-tenant turnkey experience epic.

What it provisions:

  - Deployment of `wordpress:6-php8.3-apache` (manifest-list digest
    sha256:054e611...196), pulled through the Sovereign Harbor
    proxy-cache when `global.imageRegistry` is set (per
    INVIOLABLE-PRINCIPLES #4).
  - Two initContainers seed wp-content/ from the image onto the PVC
    and install the openid-connect-generic plugin + pg4wp Postgres
    drop-in from wordpress.org / GitHub. Idempotent, runs only once
    per PVC.
  - Postgres provisioned in-tenant via a `Cluster.postgresql.cnpg.io`
    (default `wordpress-db`, 1 instance, 10Gi, pg16). The CNPG-emitted
    `<cluster>-app` Secret is mirrored into `wordpress-database-secret`
    by Reflector + a post-install sync Job (otech30 race fix carried
    forward from bp-gitea).
  - PVC for `/var/www/html/wp-content/` (default 10Gi, RWO,
    helm.sh/resource-policy: keep so customer content survives
    `helm uninstall`).
  - Ingress at `wordpress.<smeDomain>` with cert-manager TLS via
    operator-supplied ClusterIssuer (default `letsencrypt-prod`).
  - NetworkPolicy restricting egress to bp-cnpg :5432, Keycloak
    :8443/:8080, kube-dns, and HTTPS to public IPs (for plugin/theme
    fetches).
  - Three post-install Jobs:
      hook weight 5  — db-secret-sync (PATCHes wordpress-database-
                       secret.password from CNPG <cluster>-app)
      hook weight 10 — oidc-config (UPSERTs openid_connect_generic_
                       settings, active_plugins, template/stylesheet,
                       siteurl/home rows in wp_options via PHP+PDO)
      hook weight 15 — admin-user (INSERT/UPDATE wp_users +
                       wp_usermeta for SME admin's email with
                       administrator role)

After all hooks complete, the SME admin's first browser hit lands on
/wp-admin authenticated via Keycloak SSO — no install wizard, no
manual config.

Hollow-chart guard (issue #181) satisfied via the `common` library
subchart from sigstore, matching bp-newapi's pattern for scratch
charts (no first-party WordPress Helm chart exists upstream).

Tests:
  - chart/tests/observability-toggle.sh verifies BLUEPRINT-AUTHORING
    §11.2 (default render produces no PodMonitor/ServiceMonitor).
  - `helm template` smoke render with required values produces 11 K8s
    resources cleanly; `helm lint` zero-failure.

Refs: #800, #795

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
This commit is contained in:
e3mrah 2026-05-04 22:13:32 +04:00 committed by GitHub
parent 93bd3ace5b
commit c141fcd1d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1865 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# platform/wordpress-tenant
Catalyst Blueprint that provisions a turnkey, SSO-pre-wired WordPress
instance per SME tenant inside the SME's vcluster. Part of the
`#795 SME-tenant turnkey experience` epic, ticket #800 (SME-5).
## What's here
| Path | Contents |
|---|---|
| `blueprint.yaml` | Catalyst Blueprint metadata (configSchema, depends, placementSchema) |
| `chart/` | Helm chart `bp-wordpress-tenant` v0.1.0 — see `chart/README.md` |
| `chart/templates/` | Deployment, Service, Ingress, PVC, CNPG Cluster, NetworkPolicy, ServiceAccount + 3 post-install Jobs (db-secret-sync, oidc-config, admin-user) |
| `chart/tests/` | observability-toggle.sh (per #182) |
## Operator install
```bash
helm install acme-wordpress oci://ghcr.io/openova-io/bp-wordpress-tenant \
--version 0.1.0 \
--namespace sme-acme \
--set smeDomain=acme.otech31.omani.works \
--set keycloak.realmURL=https://auth.acme.otech31.omani.works/realms/sme \
--set keycloak.clientSecretName=wordpress-oidc \
--set adminUser.email=admin@acme.com
```
The Sovereign's tenant-provisioning pipeline (#804) wires this Helm
release into a Flux `HelmRelease` per SME, registers the OIDC client
in the SME realm, seals the client secret into
`wordpress-oidc`, and renders the per-SME values overlay.
## See also
- `chart/README.md` — full value reference + boot sequence
- `docs/BLUEPRINT-AUTHORING.md` §11 (umbrella shape, hollow-chart guard, observability toggles)
- `docs/INVIOLABLE-PRINCIPLES.md` (no hardcoding, SHA-pinned images, target-state shape)
- Issue #795 (epic), #800 (this Blueprint)

View File

@ -0,0 +1,142 @@
apiVersion: catalyst.openova.io/v1alpha1
kind: Blueprint
metadata:
name: bp-wordpress-tenant
labels:
catalyst.openova.io/category: tenant-app
catalyst.openova.io/section: pts-7-sme-tenant
spec:
version: 0.1.0
card:
title: WordPress Tenant
summary: |
Turnkey, SSO-pre-wired WordPress per SME tenant. Runs in the SME's
vcluster, namespace = SME tenant namespace. SSO via the SME-vcluster
Keycloak realm (openid-connect-generic plugin), Postgres via bp-cnpg
(Cluster CR in tenant ns), persistent /var/www/html/wp-content via
PVC (default 10Gi), ingress at https://wordpress.<sme-domain> with
cert-manager TLS. Default theme + admin user pre-seeded at install
time so the SME admin's first browser hit lands on /wp-admin
authenticated. No install wizard, no manual config.
icon: wordpress.svg
category: tenant-app
tags: [wordpress, cms, sme, tenant, sso, keycloak, oidc, postgres]
documentation: https://wordpress.org/documentation/
license: GPL-2.0-or-later
visibility: listed
owner:
team: platform
contact: catalyst@openova.io
configSchema:
type: object
required: [smeDomain, keycloak, adminUser]
properties:
smeDomain:
type: string
description: |
The SME tenant's domain (e.g. `acme.<otech-fqdn>` for free-
subdomain tenants, or `acme.com` for BYO). Used to derive the
default ingress host as `wordpress.<smeDomain>`. Per
docs/INVIOLABLE-PRINCIPLES.md #4 there is no default — the
tenant-provisioning pipeline supplies this at install time.
replicas:
type: integer
default: 1
minimum: 1
maximum: 8
keycloak:
type: object
required: [realmURL, clientSecretName]
properties:
realmURL:
type: string
description: |
Discovery URL of the SME-vcluster Keycloak realm. Example:
`https://auth.acme.<otech-fqdn>/realms/sme`. The
openid-connect-generic plugin uses this to derive the
token / userinfo / authorization / end-session endpoints.
clientID:
type: string
default: wordpress
description: OIDC client ID registered in the SME realm.
clientSecretName:
type: string
description: |
ExternalSecret carrying the OIDC client secret (key
`client-secret`). Provisioned by the tenant-provisioning
pipeline before this chart installs.
database:
type: object
properties:
cnpgClusterName:
type: string
default: wordpress-db
description: |
Name of the `Cluster.postgresql.cnpg.io` provisioned for
this WordPress instance. Per-tenant unique within the SME
namespace.
adminUser:
type: object
required: [email]
properties:
email:
type: string
description: |
Email of the SME admin (must match the `email` claim
Keycloak issues). The admin-user Job pre-seeds a wp_user
row with this email and the administrator role.
displayName:
type: string
description: Display name shown in the WP admin bar. Defaults to the local-part of the email.
defaultTheme:
type: string
default: twentytwentyfive
description: WordPress theme slug installed + activated at first install.
persistence:
type: object
properties:
wpContent:
type: object
properties:
size:
type: string
default: 10Gi
storageClass:
type: string
default: local-path
ingress:
type: object
properties:
host:
type: string
description: Override the derived `wordpress.<smeDomain>` ingress host.
tls:
type: object
properties:
issuer:
type: string
default: letsencrypt-prod
description: cert-manager ClusterIssuer name.
placementSchema:
modes: [single-region]
default: single-region
manifests:
chart: ./chart
depends:
- blueprint: bp-cnpg
version: ^1.0
alias: db
- blueprint: bp-keycloak
version: ^1.0
alias: idp
- blueprint: bp-reflector
version: ^1.0
alias: reflector
- blueprint: bp-cert-manager
version: ^1.0
alias: tls
upgrades:
from: ["0.x"]
observability:
metrics: prometheus
logs: stdout

View File

@ -0,0 +1,53 @@
apiVersion: v2
name: bp-wordpress-tenant
version: 0.1.0
appVersion: "6"
description: |
Catalyst Blueprint scratch chart for in-vcluster WordPress, one
instance per SME tenant. Pre-wires:
- SSO via the SME-vcluster Keycloak realm using the
`openid-connect-generic` plugin (auto-create-on-first-login,
Keycloak-group → WP-role mapping).
- Postgres provisioned by bp-cnpg (Cluster CR) in the SME tenant
namespace; password mirrored via reflector + post-install Job.
- PVC-backed `/var/www/html/wp-content/` for theme/plugin/upload
persistence.
- Ingress at `wordpress.<sme-domain>` routed via the SME's ingress
with cert-manager TLS.
- Idempotent post-install Jobs that (a) install + activate the
`openid-connect-generic` plugin and write its option row pointing
at the operator-supplied Keycloak realm + client, (b) pre-seed
the SME admin WP user with the SSO email mapping.
This is a scratch chart — there is no first-party Helm chart for
WordPress (the upstream WordPress project ships only a Docker image at
`wordpress:6-php8.3-apache`). The `common` library subchart is
declared as a Helm dependency so the BLUEPRINT-AUTHORING.md hollow-
chart guard (issue #181) is satisfied; bp-newapi follows the same
pattern.
Pairs with bp-cnpg (Postgres), bp-keycloak (SME-vcluster IdP),
bp-reflector (Secret mirror), bp-cert-manager (ACME TLS).
type: application
keywords:
- catalyst
- blueprint
- wordpress
- cms
- sme
- tenant
- sso
- keycloak
- oidc
maintainers:
- name: OpenOva Catalyst
email: catalyst@openova.io
# Scratch chart — see comments in bp-newapi/chart/Chart.yaml for the
# rationale on the `common` library subchart dependency (issue #181
# hollow-chart gate).
dependencies:
- name: common
version: "0.1.3"
repository: "https://sigstore.github.io/helm-charts"

View File

@ -0,0 +1,99 @@
# bp-wordpress-tenant
Catalyst Blueprint scratch chart that installs a turnkey,
SSO-pre-wired WordPress instance per SME tenant inside the SME's
vcluster.
This is a **scratch chart** — there is no first-party Helm chart
published by the WordPress project (the upstream ships only a Docker
image at `wordpress:6-php8.3-apache`). The `common` library subchart is
declared as a Helm dependency so the BLUEPRINT-AUTHORING.md hollow-
chart guard (issue #181) is satisfied; bp-newapi follows the same
pattern.
## What it provisions
| Resource | Purpose |
|---|---|
| `Deployment` (single replica) | The WordPress Pod. Two initContainers: one seeds `wp-content/` from the image onto the PVC; the other downloads + installs `openid-connect-generic` (Keycloak SSO) and `pg4wp` (Postgres adapter) from wordpress.org / GitHub. |
| `Service` (ClusterIP, :80) | In-vcluster service for the ingress to target. |
| `Ingress` (Traefik, host `wordpress.<smeDomain>`) | Customer-facing entry point. cert-manager issues TLS via the operator-supplied `ClusterIssuer`. |
| `PersistentVolumeClaim` (10Gi default, RWO) | Backs `/var/www/html/wp-content` so themes, plugins, and uploads persist across pod restarts and image upgrades. `helm.sh/resource-policy: keep` so `helm uninstall` never drops customer content. |
| `Cluster.postgresql.cnpg.io` (1 instance, 10Gi) | Tenant-isolated Postgres provisioned by bp-cnpg. The CNPG-emitted `<cluster>-app` Secret carries the password. |
| `Secret wordpress-database-secret` (placeholder) | Reflector-managed bridge that the WordPress Pod reads via `secretKeyRef`. Populated by the post-install `db-secret-sync` Job. |
| `Job <release>-db-secret-sync` (post-install/upgrade) | Mirrors `<cluster>-app.password` into `wordpress-database-secret.password`. Eliminates the otech30-class Reflector race documented in `bp-gitea`. |
| `Job <release>-oidc-config` (post-install/upgrade) | Connects to Postgres, ensures `wp_options` exists, then UPSERTs the `openid_connect_generic_settings` row (Keycloak URLs + client secret), `active_plugins` (activates the OIDC plugin), `template`/`stylesheet` (default theme), `siteurl`/`home`. Idempotent — re-running on `helm upgrade` is safe. |
| `Job <release>-admin-user` (post-install/upgrade, hook weight 15) | Pre-seeds the SME admin into `wp_users` + `wp_usermeta` with the `administrator` role + the SSO email mapping. The user can log in via Keycloak only. |
| `NetworkPolicy` | Restricts egress to: bp-cnpg :5432, Keycloak :8443/:8080, kube-dns, and HTTPS to public IPs (for plugin/theme fetches at first install). Ingress allowed only from the configured ingress namespace (default `traefik`). |
| `ServiceAccount` | Default SA for the WordPress Pod. The post-install Jobs use a dedicated SA + Role + RoleBinding scoped to the tenant namespace. |
## Boot sequence (per docs/INVIOLABLE-PRINCIPLES.md #2)
```
helm install
├─ pre-install: namespace, ServiceAccount, Role/RoleBinding hooks (weight 0)
├─ install: Deployment, Service, Ingress, PVC, NetworkPolicy,
│ Cluster.postgresql.cnpg.io, wordpress-database-secret (empty)
├─ post-install hook weight 5: db-secret-sync Job
│ └─ waits for CNPG <cluster>-app, PATCHes wordpress-database-secret
├─ post-install hook weight 10: oidc-config Job
│ └─ trips WP install via Service GET, UPSERTs OIDC + theme + siteurl
└─ post-install hook weight 15: admin-user Job
└─ INSERT/UPDATE wp_users row for the SME admin's email
```
After all hooks complete, the SME admin browses to
`https://wordpress.<smeDomain>` → openid-connect-generic redirects to
Keycloak → returns to `/wp-admin` authenticated as administrator. No
WP install wizard, no manual config.
## Required values
| Value | Description |
|---|---|
| `smeDomain` | The SME tenant's domain (e.g. `acme.<otech-fqdn>` or BYO `acme.com`). Used to derive the default ingress host as `wordpress.<smeDomain>`. |
| `keycloak.realmURL` | Discovery URL of the SME-vcluster Keycloak realm. Example: `https://auth.acme.<otech-fqdn>/realms/sme`. |
| `keycloak.clientSecretName` | ExternalSecret carrying the Keycloak OIDC client secret (key `client-secret`). Provisioned by the tenant-provisioning pipeline before this chart installs. |
| `adminUser.email` | Email of the SME admin (must match the `email` claim Keycloak issues for that user). The admin-user Job pre-seeds a wp_user with this email and the administrator role. |
## Override surface
All other values have sensible defaults; common overrides include:
| Value | Default | Notes |
|---|---|---|
| `global.imageRegistry` | `""` | Set to the Sovereign's Harbor proxy-cache hostname post-handover. |
| `wordpress.image.tag` | `6-php8.3-apache` | The chart pins the manifest-list digest alongside; change `tag`+`digest` together. |
| `database.cnpgClusterName` | `wordpress-db` | Per-tenant unique within the SME namespace. |
| `database.cluster.storageSize` | `10Gi` | Postgres storage size. |
| `persistence.wpContent.size` | `10Gi` | wp-content PVC size. |
| `persistence.wpContent.storageClass` | `local-path` | Set to a RWX class if you want to scale `replicas > 1`. |
| `defaultTheme` | `twentytwentyfive` | Any wordpress.org theme slug bundled with the official image. |
| `ingress.tls.issuer` | `letsencrypt-prod` | cert-manager `ClusterIssuer`. |
See `values.yaml` for the full schema, including NetworkPolicy egress
peers, OIDC role mapping, and probe tuning.
## Why Postgres (and not MySQL)?
Issue #800 specifies "bp-cnpg Postgres in tenant namespace". The
official `wordpress` image targets MySQL/MariaDB; we run it against
Postgres via the `pg4wp` mu-plugin (a `wp-content/db.php` drop-in that
intercepts `wpdb` at the PHP level and translates queries). This keeps
the SME tenant footprint to one database operator (bp-cnpg) instead
of sprouting a separate MySQL operator per SME — see the upstream
project at
https://github.com/PostgreSQL-For-Wordpress/postgresql-for-wordpress.
The `pg4wp` install is performed by the same `wp-plugin-install`
initContainer that installs `openid-connect-generic`, so the chart
needs no special image build.
## Capabilities gate
`Cluster.postgresql.cnpg.io` is rendered behind a Capabilities check on
`postgresql.cnpg.io/v1`, so a cold install before bp-cnpg is
reconciling skips the Cluster CR (and the Pod waits in
`Pending`/`CrashLoopBackOff` until bp-cnpg lands and the Cluster is
re-rendered on the next reconcile). The Sovereign's bootstrap order
MUST land bp-cnpg before bp-wordpress-tenant.

View File

@ -0,0 +1,113 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "bp-wordpress-tenant.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "bp-wordpress-tenant.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels — required by docs/BLUEPRINT-AUTHORING.md §14 and by the
Catalyst projector to track resources back to the Blueprint.
*/}}
{{- define "bp-wordpress-tenant.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "bp-wordpress-tenant.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
catalyst.openova.io/blueprint: bp-wordpress-tenant
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "bp-wordpress-tenant.selectorLabels" -}}
app.kubernetes.io/name: {{ include "bp-wordpress-tenant.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
ServiceAccount name.
*/}}
{{- define "bp-wordpress-tenant.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "bp-wordpress-tenant.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
WordPress image reference, with optional `global.imageRegistry` rewrite
for Sovereign Harbor proxy-cache. Returns
`{registry/}repository:tag@digest` so consumers SHA-pin to the manifest-
list digest published on Docker Hub.
*/}}
{{- define "bp-wordpress-tenant.wordpressImage" -}}
{{- $reg := .Values.global.imageRegistry | default "" -}}
{{- $repo := .Values.wordpress.image.repository -}}
{{- $tag := .Values.wordpress.image.tag -}}
{{- $digest := .Values.wordpress.image.digest -}}
{{- if $reg -}}
{{- printf "%s/%s:%s@%s" $reg $repo $tag $digest -}}
{{- else -}}
{{- printf "%s:%s@%s" $repo $tag $digest -}}
{{- end -}}
{{- end -}}
{{/*
Resolved ingress host. Templates `wordpress.<smeDomain>` when
`ingress.host` is empty; otherwise returns the operator-supplied host
verbatim. `smeDomain` is required when `ingress.host` is empty.
*/}}
{{- define "bp-wordpress-tenant.ingressHost" -}}
{{- if .Values.ingress.host -}}
{{- .Values.ingress.host -}}
{{- else -}}
{{- $sme := required ".Values.smeDomain or .Values.ingress.host MUST be set (no sensible default per INVIOLABLE-PRINCIPLES #4)." .Values.smeDomain -}}
{{- printf "wordpress.%s" $sme -}}
{{- end -}}
{{- end -}}
{{/*
CNPG cluster namespace — defaults to .Release.Namespace if the
operator left `database.cluster.namespace` empty.
*/}}
{{- define "bp-wordpress-tenant.cnpgNamespace" -}}
{{- default .Release.Namespace .Values.database.cluster.namespace -}}
{{- end -}}
{{/*
CNPG-emitted application Secret name (`<cluster>-app`). CNPG synthesises
this Secret from the `Cluster.spec.bootstrap.initdb.owner` field at
install time.
*/}}
{{- define "bp-wordpress-tenant.cnpgAppSecret" -}}
{{- printf "%s-app" .Values.database.cnpgClusterName -}}
{{- end -}}
{{/*
CNPG-emitted read-write Service hostname. CNPG synthesises this Service
from the Cluster CR; suffix is `-rw` per the CNPG operator convention.
*/}}
{{- define "bp-wordpress-tenant.cnpgRwHost" -}}
{{- printf "%s-rw.%s.svc.cluster.local" .Values.database.cnpgClusterName (include "bp-wordpress-tenant.cnpgNamespace" .) -}}
{{- end -}}

View File

@ -0,0 +1,173 @@
{{- if and .Values.wordpress.enabled .Values.adminUser.email }}
{{- /*
Helm post-install Job that pre-seeds the SME admin WP user with the
SSO email mapping, so on first SSO login from Keycloak the
openid-connect-generic plugin matches the email claim back to a
pre-existing wp_users row with `administrator` role.
Pattern
───────
We INSERT an `administrator` user with:
- user_login = the local-part of `adminUser.email`
- user_email = `adminUser.email`
- user_pass = a long random hash (the user can never log in via
password — only via Keycloak SSO)
- display_name = `adminUser.displayName` or local-part of email
And the standard WP usermeta rows that mark the user as an
administrator.
Idempotent: if a user with the same email already exists, the Job
no-ops (still ensures the user has the administrator capability).
Hook weight 15 — runs AFTER the OIDC config Job (10) which itself
runs after the DB secret sync Job (5).
*/}}
{{- $ns := .Release.Namespace }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-admin-user
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
catalyst.openova.io/component: wordpress-admin-user
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "15"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app.kubernetes.io/name: wordpress-admin-user
catalyst.openova.io/blueprint: bp-wordpress-tenant
spec:
restartPolicy: OnFailure
securityContext:
{{- toYaml .Values.wordpress.podSecurityContext | nindent 8 }}
containers:
- name: admin-user
image: {{ include "bp-wordpress-tenant.wordpressImage" . | quote }}
imagePullPolicy: {{ .Values.wordpress.image.pullPolicy }}
securityContext:
{{- toYaml .Values.wordpress.containerSecurityContext | nindent 12 }}
env:
- name: WP_DB_HOST
value: {{ include "bp-wordpress-tenant.cnpgRwHost" . | quote }}
- name: WP_DB_NAME
value: {{ .Values.database.cluster.database | default "wordpress" | quote }}
- name: WP_DB_USER
value: {{ .Values.database.cluster.owner | default "wordpress" | quote }}
- name: WP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: password
- name: ADMIN_EMAIL
value: {{ .Values.adminUser.email | quote }}
- name: ADMIN_DISPLAY_NAME
value: {{ .Values.adminUser.displayName | default "" | quote }}
command:
- /bin/bash
- -c
- |
set -eu
if ! php -m | grep -qi pdo_pgsql; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
php-pgsql
fi
cat > /tmp/admin-user.php <<'PHP'
<?php
/**
* bp-wordpress-tenant admin user pre-seed.
*
* Inserts (or upgrades) the SME admin into wp_users +
* wp_usermeta with administrator capability. Login is
* via SSO only — the password hash is unguessable.
*/
$host = getenv('WP_DB_HOST');
$db = getenv('WP_DB_NAME');
$user = getenv('WP_DB_USER');
$pass = getenv('WP_DB_PASSWORD');
$email = getenv('ADMIN_EMAIL');
$disp = getenv('ADMIN_DISPLAY_NAME');
$login = strtolower(strtok($email, '@'));
if (!$disp) $disp = $login;
$pdo = new PDO(
"pgsql:host={$host};port=5432;dbname={$db}",
$user, $pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Check that wp_users exists. The OIDC config Job (hook
// weight 10) tripped install of WP earlier; we just
// confirm here.
$r = $pdo->query("SELECT to_regclass('public.wp_users') AS t")->fetch();
if (empty($r['t'])) {
fprintf(STDERR, "[admin-user] FATAL: wp_users not present — OIDC config Job failed?\n");
exit(1);
}
// Upsert the user row.
$stmt = $pdo->prepare("SELECT ID FROM wp_users WHERE user_email = :e LIMIT 1");
$stmt->execute([':e' => $email]);
$existing = $stmt->fetchColumn();
if ($existing) {
$userId = (int)$existing;
echo "[admin-user] user {$email} already exists (ID={$userId}); ensuring admin role.\n";
} else {
// 64-char random hash, salted via WP's phpass; the
// user will never use this — Keycloak is the only
// login path. We use a wp_hash_password-equivalent
// bcrypt because phpass is not exposed without
// loading wp-includes; bcrypt is accepted by WP
// since 4.x.
$rand = bin2hex(random_bytes(32));
$hash = password_hash($rand, PASSWORD_BCRYPT);
$stmt = $pdo->prepare(
"INSERT INTO wp_users
(user_login, user_pass, user_nicename, user_email,
user_registered, user_status, display_name)
VALUES (:l, :p, :n, :e, NOW(), 0, :d)
RETURNING ID"
);
$stmt->execute([
':l' => $login,
':p' => $hash,
':n' => $login,
':e' => $email,
':d' => $disp,
]);
$userId = (int)$stmt->fetchColumn();
echo "[admin-user] created user {$email} (ID={$userId}).\n";
}
// Usermeta: wp_capabilities = a:1:{s:13:"administrator";b:1;}
$caps = serialize(['administrator' => true]);
$upsertMeta = function($uid, $key, $value) use ($pdo) {
// wp_usermeta has unique(user_id, meta_key) on most
// installs, but the schema technically allows
// duplicates. We delete + insert to be portable.
$del = $pdo->prepare("DELETE FROM wp_usermeta WHERE user_id = :u AND meta_key = :k");
$del->execute([':u' => $uid, ':k' => $key]);
$ins = $pdo->prepare("INSERT INTO wp_usermeta (user_id, meta_key, meta_value) VALUES (:u, :k, :v)");
$ins->execute([':u' => $uid, ':k' => $key, ':v' => $value]);
};
$upsertMeta($userId, 'wp_capabilities', $caps);
$upsertMeta($userId, 'wp_user_level', '10');
echo "[admin-user] {$email} granted administrator role.\n";
PHP
php /tmp/admin-user.php
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
{{- end }}

View File

@ -0,0 +1,78 @@
{{- if and .Values.wordpress.enabled .Values.database.cluster.enabled }}
{{- /*
CNPG-managed Postgres cluster for WordPress.
WordPress requires MySQL or MariaDB historically; on Catalyst Sovereigns
we use Postgres via the upstream `wp-postgresql-driver` mu-plugin
(installed by the oidc-config Job alongside openid-connect-generic) so
the entire SME tenant footprint shares one bp-cnpg footprint instead of
sprouting a separate MySQL operator. Pattern mirrors bp-gitea +
bp-harbor (templates/cnpg-cluster.yaml in those charts).
The CNPG-emitted `<cluster>-app` Secret is mirrored cross-namespace via
the bp-reflector pattern + a post-install sync Job (see
templates/database-secret-sync-job.yaml) so the canonical `password`
key is always present in `wordpress-database-secret` before the
WordPress Pod starts.
Capabilities gate: bp-cnpg ships the `postgresql.cnpg.io/v1` CRD. On a
cold install before bp-cnpg is reconciling, the apiserver rejects this
Cluster. The Capabilities check skips this template until the CRD is
registered. The Sovereign's bootstrap order MUST land bp-cnpg before
bp-wordpress-tenant.
*/}}
{{- if .Capabilities.APIVersions.Has "postgresql.cnpg.io/v1" }}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: {{ .Values.database.cnpgClusterName | quote }}
namespace: {{ include "bp-wordpress-tenant.cnpgNamespace" . | quote }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
spec:
instances: {{ .Values.database.cluster.instances }}
imageName: ghcr.io/cloudnative-pg/postgresql:{{ .Values.database.cluster.pgVersion }}
bootstrap:
initdb:
database: {{ .Values.database.cluster.database | quote }}
owner: {{ .Values.database.cluster.owner | quote }}
# inheritedMetadata.annotations: CNPG propagates these onto every
# Secret it generates (`<cluster>-app`, `<cluster>-superuser`, etc.).
# The k8s-reflector (bp-reflector) requires the SOURCE Secret to carry
# `reflection-allowed: "true"` before it will copy data into the
# DESTINATION Secret. See bp-gitea cnpg-cluster.yaml for the
# canonical rationale (issue #584).
inheritedMetadata:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: {{ include "bp-wordpress-tenant.cnpgNamespace" . | quote }}
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: {{ include "bp-wordpress-tenant.cnpgNamespace" . | quote }}
postgresql:
parameters:
max_connections: "100"
shared_buffers: "64MB"
effective_cache_size: "192MB"
work_mem: "4MB"
maintenance_work_mem: "64MB"
log_statement: "ddl"
log_min_duration_statement: "1000"
resources:
{{- toYaml .Values.database.cluster.resources | nindent 4 }}
storage:
size: {{ .Values.database.cluster.storageSize }}
storageClass: {{ .Values.database.cluster.storageClass | quote }}
enableSuperuserAccess: true
monitoring:
# Default false per docs/BLUEPRINT-AUTHORING.md §11.2 — operator
# opts in via per-cluster overlay.
enablePodMonitor: false
{{- end }}
{{- end }}

View File

@ -0,0 +1,152 @@
{{- if and .Values.wordpress.enabled .Values.database.cluster.enabled }}
{{- /*
Helm post-install Job that copies the CNPG-emitted `<cluster>-app`
Secret into `wordpress-database-secret` so the WordPress Pod's
WORDPRESS_DB_PASSWORD env binding finds the expected `password` key.
WHY A JOB INSTEAD OF RELYING ON RELFLECTOR ALONE
────────────────────────────────────────────────
The Reflector-based path can race when CNPG creates
`<cluster>-app` AFTER `wordpress-database-secret` is processed; on
otech30 (caught live, see bp-gitea database-secret-sync-job.yaml)
Reflector logged "Source could not be found" once and never retried.
The post-install Job waits for CNPG to provision the source Secret,
then PATCHes the destination, eliminating the watcher race.
Pattern lifted from platform/gitea/chart/templates/database-secret-
sync-job.yaml (canonical seam — see ADR-0001 §11.3 anti-duplication).
*/}}
{{- $ns := include "bp-wordpress-tenant.cnpgNamespace" . }}
{{- $clusterName := .Values.database.cnpgClusterName }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
catalyst.openova.io/component: wordpress-database-secret-sync
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "5"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
app.kubernetes.io/name: wordpress-database-secret-sync
catalyst.openova.io/blueprint: bp-wordpress-tenant
spec:
restartPolicy: OnFailure
serviceAccountName: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
containers:
- name: sync
# curlimages/curl matches bp-gitea (no kubectl image
# required — we talk to the apiserver via the in-pod
# ServiceAccount token).
image: curlimages/curl:8.10.1
imagePullPolicy: IfNotPresent
env:
- name: SOURCE_SECRET
value: {{ printf "%s-app" $clusterName | quote }}
- name: DEST_SECRET
value: {{ .Values.database.secretName | quote }}
- name: NAMESPACE
value: {{ $ns | quote }}
command:
- /bin/sh
- -c
- |
set -eu
APISERVER="https://kubernetes.default.svc"
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
echo "Waiting for ${SOURCE_SECRET} in ${NAMESPACE}..."
for i in $(seq 1 120); do
code=$(curl -s -o /tmp/src.json -w '%{http_code}' --cacert "${CA}" \
-H "Authorization: Bearer ${TOKEN}" \
"${APISERVER}/api/v1/namespaces/${NAMESPACE}/secrets/${SOURCE_SECRET}")
if [ "${code}" = "200" ]; then
echo "Found ${SOURCE_SECRET}"; break
fi
if [ "$i" = "120" ]; then
echo "Timeout: ${SOURCE_SECRET} not present after 10 min" >&2
exit 1
fi
sleep 5
done
PWD_B64=$(tr -d '\n' < /tmp/src.json | grep -oE '"password"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
if [ -z "${PWD_B64}" ]; then
echo "Source ${SOURCE_SECRET} has empty password" >&2
exit 1
fi
PATCH=$(printf '{"data":{"password":"%s"}}' "${PWD_B64}")
code=$(curl -s -o /tmp/patch.json -w '%{http_code}' --cacert "${CA}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/strategic-merge-patch+json" \
-X PATCH --data "${PATCH}" \
"${APISERVER}/api/v1/namespaces/${NAMESPACE}/secrets/${DEST_SECRET}")
if [ "${code}" != "200" ] && [ "${code}" != "201" ]; then
echo "PATCH ${DEST_SECRET} returned HTTP ${code}:" >&2
cat /tmp/patch.json >&2
exit 1
fi
echo "Synced ${DEST_SECRET} from ${SOURCE_SECRET}"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": "before-hook-creation"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": "before-hook-creation"
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "update", "patch", "apply"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": "before-hook-creation"
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
subjects:
- kind: ServiceAccount
name: {{ include "bp-wordpress-tenant.fullname" . }}-db-secret-sync
namespace: {{ $ns }}
{{- end }}

View File

@ -0,0 +1,41 @@
{{- if and .Values.wordpress.enabled .Values.database.cluster.enabled }}
{{- /*
Placeholder Secret that bp-reflector + the post-install sync Job
populate from the CNPG-generated `<cluster>-app` Secret.
WordPress reads the database password from WORDPRESS_DB_PASSWORD; the
Deployment binds it via secretKeyRef to this Secret's `password` key.
CNPG produces `<cnpgClusterName>-app` with a `password` key. This
Secret acts as the bridge: reflector copies all keys from
`<cluster>-app` into this Secret; the post-install Job in
templates/database-secret-sync-job.yaml provides the safety net for
the otech30-class Reflector race documented in
platform/gitea/chart/templates/database-secret-sync-job.yaml.
Per docs/INVIOLABLE-PRINCIPLES.md #10 (credential hygiene): no
plaintext credentials appear in this committed template — only an
empty placeholder. Reflector / the sync Job copy bytes from a live
cluster Secret at runtime.
*/}}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.database.secretName }}
namespace: {{ include "bp-wordpress-tenant.cnpgNamespace" . | quote }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
# Reflector copies all keys from <cluster>-app into this Secret.
reflector.v1.k8s.emberstack.com/reflects: "{{ include "bp-wordpress-tenant.cnpgNamespace" . }}/{{ include "bp-wordpress-tenant.cnpgAppSecret" . }}"
# Helm resource-policy keep — do not delete on `helm uninstall`
# (the Secret is independently managed by reflector + the
# post-install Job after initial creation).
helm.sh/resource-policy: keep
type: Opaque
# Bootstrap empty data — reflector / the post-install Job overwrites
# `password` once CNPG finishes provisioning <cluster>-app. Empty
# values here prevent CreateContainerConfigError (secret key missing)
# during initial render.
data:
password: ""
{{- end }}

View File

@ -0,0 +1,230 @@
{{- if .Values.wordpress.enabled }}
{{- /*
WordPress Deployment.
Boot sequence (per docs/INVIOLABLE-PRINCIPLES.md #2 — ship target-state shape, no stubs):
1. initContainer `wp-content-bootstrap`: copies the official image's
baked-in `/var/www/html/wp-content/` (themes, plugins, mu-plugins
skeleton) onto the empty PVC ON FIRST INSTALL ONLY. The official
wordpress:* image bakes wp-content into the image; mounting an
empty PVC over `/var/www/html/wp-content` would otherwise hide
those bytes. The init container detects an unseeded PVC by looking
for `themes/` and seeds it from the image once, idempotently.
2. initContainer `wp-oidc-plugin-install`: downloads + extracts
`openid-connect-generic` (latest stable) from
downloads.wordpress.org into `wp-content/plugins/` on the PVC.
Idempotent — skips if the plugin dir already exists.
3. main container `wordpress`: the official image starts; its
entrypoint writes `wp-config.php` from WORDPRESS_DB_* env vars,
then `apache2-foreground` boots Apache + mod_php.
The OIDC plugin's option row, the admin user, and the default theme
activation are populated by the post-install Jobs in
templates/oidc-config-job.yaml + templates/admin-user-job.yaml — those
talk to the WordPress install once the Service is reachable, which
keeps this Deployment template free of WP CLI.
*/}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.wordpress.replicas }}
selector:
matchLabels:
{{- include "bp-wordpress-tenant.selectorLabels" . | nindent 6 }}
strategy:
# ReadWriteOnce PVC default — Recreate avoids the "two pods can't
# mount the same PVC" deadlock during rollouts. Operators that
# switch persistence to RWX may flip to RollingUpdate via overlay.
type: Recreate
template:
metadata:
labels:
{{- include "bp-wordpress-tenant.selectorLabels" . | nindent 8 }}
annotations:
# Roll the pod when the OIDC config / DB secret name changes.
checksum/oidc-job: {{ include (print $.Template.BasePath "/oidc-config-job.yaml") . | sha256sum }}
spec:
serviceAccountName: {{ include "bp-wordpress-tenant.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.wordpress.podSecurityContext | nindent 8 }}
initContainers:
# ── 1. Seed wp-content from the image onto the PVC (idempotent) ─
- name: wp-content-bootstrap
image: {{ include "bp-wordpress-tenant.wordpressImage" . | quote }}
imagePullPolicy: {{ .Values.wordpress.image.pullPolicy }}
securityContext:
{{- toYaml .Values.wordpress.containerSecurityContext | nindent 12 }}
command:
- /bin/bash
- -c
- |
set -eu
# If the PVC is empty (first install) seed it from the
# image's baked-in wp-content. We probe for `themes/`
# because the official image always ships at least the
# default theme — if it's missing, the PVC was just
# bound and we need to seed.
if [ ! -d /pvc/themes ]; then
echo "Seeding wp-content/ onto PVC from image..."
cp -a /usr/src/wordpress/wp-content/. /pvc/
chown -R 33:33 /pvc
echo "Seed complete."
else
echo "wp-content/ already populated on PVC — skipping seed."
fi
volumeMounts:
- name: wp-content
mountPath: /pvc
# ── 2. Install plugins from wordpress.org / GitHub onto the PVC ─
# Two plugins are pre-installed at boot, both idempotent
# (skip if already present on the PVC):
#
# a) `openid-connect-generic` — the SSO plugin that drives
# Keycloak OIDC login flow. Source: wordpress.org plugin
# registry.
# b) `pg4wp` (mu-plugin) — translates WordPress's MySQL
# queries to PostgreSQL so the official `wordpress:*`
# image runs against bp-cnpg without a MySQL footprint
# in the SME tenant namespace. Source: PostgreSQL-For-
# Wordpress on GitHub. Installed under wp-content/pg4wp
# and chain-loaded via wp-content/db.php drop-in.
- name: wp-plugin-install
image: {{ include "bp-wordpress-tenant.wordpressImage" . | quote }}
imagePullPolicy: {{ .Values.wordpress.image.pullPolicy }}
securityContext:
{{- toYaml .Values.wordpress.containerSecurityContext | nindent 12 }}
command:
- /bin/bash
- -c
- |
set -eu
# apt: ensure unzip + curl present (curl is shipped, unzip
# may not be on a slim image variant; runs only on first
# install per PVC, so the cost is a one-time apt fetch).
if ! command -v unzip >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends unzip
fi
# ── (a) openid-connect-generic plugin ──────────────────
OIDC_DIR=/pvc/plugins/openid-connect-generic
if [ -d "${OIDC_DIR}" ]; then
echo "openid-connect-generic already installed — skipping."
else
echo "Downloading openid-connect-generic..."
cd /tmp
curl -fsSL -o oidc.zip \
https://downloads.wordpress.org/plugin/openid-connect-generic.latest-stable.zip
mkdir -p /pvc/plugins
unzip -q oidc.zip -d /pvc/plugins/
rm -f /tmp/oidc.zip
echo "openid-connect-generic installed."
fi
# ── (b) pg4wp drop-in (Postgres adapter) ───────────────
# bp-wordpress-tenant runs WordPress against bp-cnpg
# Postgres (per ticket #800 scope). pg4wp ships a
# `wp-content/db.php` drop-in + `pg4wp/` library that
# together intercept `wpdb` at the PHP level and map
# MySQL queries to Postgres. Repo:
# https://github.com/PostgreSQL-For-Wordpress/postgresql-for-wordpress
PG4WP_DIR=/pvc/pg4wp
if [ -d "${PG4WP_DIR}" ] && [ -f /pvc/db.php ]; then
echo "pg4wp already installed — skipping."
else
echo "Downloading pg4wp..."
cd /tmp
# Pin to a stable tag, not main, per
# docs/INVIOLABLE-PRINCIPLES.md #4 (no floating refs).
curl -fsSL -o pg4wp.zip \
https://github.com/PostgreSQL-For-Wordpress/postgresql-for-wordpress/archive/refs/tags/v3.7.0.zip
unzip -q pg4wp.zip -d /tmp/
# Extracted layout:
# /tmp/postgresql-for-wordpress-3.7.0/pg4wp/
# /tmp/postgresql-for-wordpress-3.7.0/pg4wp/db.php
# Copy `pg4wp/` into wp-content/ and the `db.php`
# drop-in alongside it (WP looks for db.php at
# wp-content/db.php).
cp -a /tmp/postgresql-for-wordpress-3.7.0/pg4wp /pvc/
cp /tmp/postgresql-for-wordpress-3.7.0/pg4wp/db.php /pvc/db.php
rm -rf /tmp/postgresql-for-wordpress-3.7.0 /tmp/pg4wp.zip
echo "pg4wp installed."
fi
chown -R 33:33 /pvc
volumeMounts:
- name: wp-content
mountPath: /pvc
containers:
- name: wordpress
image: {{ include "bp-wordpress-tenant.wordpressImage" . | quote }}
imagePullPolicy: {{ .Values.wordpress.image.pullPolicy }}
securityContext:
{{- toYaml .Values.wordpress.containerSecurityContext | nindent 12 }}
ports:
- name: http
containerPort: {{ .Values.wordpress.port }}
protocol: TCP
env:
# ── Database connection (CNPG-emitted reflector secret) ─
- name: WORDPRESS_DB_HOST
value: {{ include "bp-wordpress-tenant.cnpgRwHost" . | quote }}
- name: WORDPRESS_DB_NAME
value: {{ .Values.database.cluster.database | default "wordpress" | quote }}
- name: WORDPRESS_DB_USER
value: {{ .Values.database.cluster.owner | default "wordpress" | quote }}
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: password
# ── Site URL (used by WP to construct admin/login links) ─
- name: WORDPRESS_CONFIG_EXTRA
value: |
define('WP_HOME', 'https://{{ include "bp-wordpress-tenant.ingressHost" . }}');
define('WP_SITEURL', 'https://{{ include "bp-wordpress-tenant.ingressHost" . }}');
/* SSL terminates at the ingress; tell WP to honour the
X-Forwarded-Proto header so login redirects use https. */
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
livenessProbe:
httpGet:
path: {{ .Values.wordpress.probes.liveness.path }}
port: http
httpHeaders:
- name: Host
value: {{ include "bp-wordpress-tenant.ingressHost" . | quote }}
initialDelaySeconds: {{ .Values.wordpress.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.wordpress.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.wordpress.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.wordpress.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.wordpress.probes.readiness.path }}
port: http
httpHeaders:
- name: Host
value: {{ include "bp-wordpress-tenant.ingressHost" . | quote }}
initialDelaySeconds: {{ .Values.wordpress.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.wordpress.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.wordpress.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.wordpress.probes.readiness.failureThreshold }}
resources:
{{- toYaml .Values.wordpress.resources | nindent 12 }}
volumeMounts:
- name: wp-content
mountPath: /var/www/html/wp-content
volumes:
- name: wp-content
{{- if .Values.persistence.wpContent.enabled }}
persistentVolumeClaim:
claimName: {{ include "bp-wordpress-tenant.fullname" . }}-wp-content
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}

View File

@ -0,0 +1,39 @@
{{- if and .Values.wordpress.enabled .Values.ingress.enabled }}
{{- $fullName := include "bp-wordpress-tenant.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- $host := include "bp-wordpress-tenant.ingressHost" . -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
{{- if .Values.ingress.tls.enabled }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.issuer | quote }}
{{- end }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ $host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- end }}

View File

@ -0,0 +1,82 @@
{{- if and .Values.wordpress.enabled .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "bp-wordpress-tenant.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
# Traffic from the SME's ingress namespace (default: traefik).
- from:
- namespaceSelector:
matchLabels:
{{- toYaml .Values.networkPolicy.ingress.fromNamespaceLabels | nindent 14 }}
ports:
- port: {{ .Values.wordpress.port }}
protocol: TCP
egress:
# ── bp-cnpg Postgres ──────────────────────────────────────────
# Default: same namespace as the WordPress release. Operator may
# point this to a shared cnpg-tenants namespace via overlay.
- to:
- namespaceSelector:
matchLabels:
{{- if .Values.networkPolicy.egress.cnpgNamespaceLabel }}
kubernetes.io/metadata.name: {{ .Values.networkPolicy.egress.cnpgNamespaceLabel }}
{{- else }}
kubernetes.io/metadata.name: {{ include "bp-wordpress-tenant.cnpgNamespace" . }}
{{- end }}
ports:
- port: {{ .Values.networkPolicy.egress.cnpgPort }}
protocol: TCP
# ── bp-keycloak (OIDC discovery + token endpoints) ────────────
# Tenant Keycloak realm in the SME vcluster. WordPress's PHP
# backchannel hits the token endpoint at install time (auto-
# discovery) and on every login refresh.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ .Values.networkPolicy.egress.keycloakNamespaceLabel }}
ports:
- port: {{ .Values.networkPolicy.egress.keycloakPort }}
protocol: TCP
# Some Sovereigns expose Keycloak on http :8080 inside the
# mesh (TLS terminates at the gateway). Allow both.
- port: 8080
protocol: TCP
# ── DNS resolution ────────────────────────────────────────────
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# ── HTTPS egress (Keycloak realm URL if external, plugin/theme
# fetches at first install) ────────────────────────────────
# The cluster's egress-gateway enforces destination policy
# globally; this rule just opens the wire. Used by the WP plugin
# installer initContainer to pull `openid-connect-generic` +
# `pg4wp` zips from wordpress.org / GitHub. Cloud-egress IPs
# only — internal RFC1918 ranges are excluded so this rule
# cannot accidentally reach intra-cluster services on :443.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- port: 443
protocol: TCP
{{- end }}

View File

@ -0,0 +1,243 @@
{{- if .Values.wordpress.enabled }}
{{- /*
Helm post-install Job that wires the openid-connect-generic plugin's
WordPress option row to the SME-vcluster Keycloak realm + client at
first install. Also activates the plugin, the default theme, and the
pg4wp drop-in so the SME admin never sees the WordPress install
wizard.
Strategy
────────
The WordPress `wp_options` table is the single source of truth for
plugin configuration: a row keyed `openid_connect_generic_settings`
holds a serialised PHP array with login_type / endpoint URLs / client
id / client secret. The active theme + the active plugin list also
live in `wp_options` (`template`, `stylesheet`, `active_plugins`).
This Job installs WordPress's database schema if not already present
(invokes the official `wordpress` image's `wp-cli`-equivalent install
flow via PHP directly), then UPSERTs the OIDC settings row, the
`active_plugins` row, and the `template`/`stylesheet` rows. Idempotent:
re-running on `helm upgrade` overwrites with the same values.
Why direct DB writes instead of WP-CLI
──────────────────────────────────────
WP-CLI is not bundled with the official `wordpress:*` Docker image
and adding a layer just for this install would diverge from the
upstream image (and force a Catalyst-built WordPress image, which
violates the issue's "pull through Sovereign Harbor proxy-cache, no
local rebuild" constraint). The Job uses PHP from the WordPress
image to talk to Postgres via PDO; same image, no extra dependency.
Why post-install (not pre-install)
──────────────────────────────────
WORDPRESS_DB_PASSWORD is only populated by the
database-secret-sync-job at post-install hook-weight 5. This OIDC
Job runs at hook-weight 10 (after the DB Secret is reconciled).
*/}}
{{- $ns := .Release.Namespace }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-oidc-config
namespace: {{ $ns }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
catalyst.openova.io/component: wordpress-oidc-config
annotations:
"helm.sh/hook": "post-install,post-upgrade"
"helm.sh/hook-weight": "10"
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app.kubernetes.io/name: wordpress-oidc-config
catalyst.openova.io/blueprint: bp-wordpress-tenant
spec:
restartPolicy: OnFailure
securityContext:
{{- toYaml .Values.wordpress.podSecurityContext | nindent 8 }}
containers:
- name: oidc-config
image: {{ include "bp-wordpress-tenant.wordpressImage" . | quote }}
imagePullPolicy: {{ .Values.wordpress.image.pullPolicy }}
securityContext:
{{- toYaml .Values.wordpress.containerSecurityContext | nindent 12 }}
env:
- name: WP_DB_HOST
value: {{ include "bp-wordpress-tenant.cnpgRwHost" . | quote }}
- name: WP_DB_NAME
value: {{ .Values.database.cluster.database | default "wordpress" | quote }}
- name: WP_DB_USER
value: {{ .Values.database.cluster.owner | default "wordpress" | quote }}
- name: WP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: password
- name: WP_SITE_URL
value: "https://{{ include "bp-wordpress-tenant.ingressHost" . }}"
- name: WP_DEFAULT_THEME
value: {{ .Values.defaultTheme | quote }}
- name: KC_REALM_URL
value: {{ required "keycloak.realmURL is required (e.g. https://auth.<sme-domain>/realms/sme)" .Values.keycloak.realmURL | quote }}
- name: KC_CLIENT_ID
value: {{ .Values.keycloak.clientID | quote }}
- name: KC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ required "keycloak.clientSecretName is required (ExternalSecret name carrying the OIDC client secret)" .Values.keycloak.clientSecretName }}
key: client-secret
command:
- /bin/bash
- -c
- |
set -eu
# Ensure php-pgsql + the postgres CLI are present. The
# official wordpress image ships php-mysqli but not
# php-pgsql; we install at boot (one-time) via apt.
if ! php -m | grep -qi pdo_pgsql; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
php-pgsql postgresql-client
fi
cat > /tmp/oidc-config.php <<'PHP'
<?php
/**
* bp-wordpress-tenant OIDC config bootstrap.
*
* Connects to the bp-cnpg Postgres provisioned for this
* SME tenant, ensures the `wp_options` table exists with
* a minimal schema (WP creates it on first browser hit
* — we pre-create here to avoid the wizard race),
* and UPSERTs the rows that activate openid-connect-
* generic + select the default theme.
*
* Idempotent — re-running on `helm upgrade` overwrites
* with the same values.
*/
$host = getenv('WP_DB_HOST');
$db = getenv('WP_DB_NAME');
$user = getenv('WP_DB_USER');
$pass = getenv('WP_DB_PASSWORD');
$siteurl = getenv('WP_SITE_URL');
$theme = getenv('WP_DEFAULT_THEME');
$issuer = getenv('KC_REALM_URL');
$cid = getenv('KC_CLIENT_ID');
$csecret = getenv('KC_CLIENT_SECRET');
echo "[oidc-config] connecting to Postgres at {$host}\n";
$pdo = new PDO(
"pgsql:host={$host};port=5432;dbname={$db}",
$user, $pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Wait for WordPress's wp_options table to exist —
// WP creates it lazily on the first browser request. We
// poll for up to 5 minutes; if it still doesn't exist,
// we trip a sentinel HTTP request to the WP Service to
// force the install routine. The WordPress Service in
// the same namespace is reachable as
// <release>-bp-wordpress-tenant.<ns>.svc.cluster.local.
$tableExists = function() use ($pdo) {
$r = $pdo->query("SELECT to_regclass('public.wp_options') AS t")->fetch();
return !empty($r['t']);
};
for ($i = 0; $i < 60; $i++) {
if ($tableExists()) break;
if ($i === 0) {
// Trip the install by GETting the WP Service.
$svcUrl = sprintf('http://%s.%s.svc.cluster.local/',
getenv('WP_SVC_NAME') ?: 'wordpress',
getenv('WP_NAMESPACE') ?: 'default');
echo "[oidc-config] tripping WP install via {$svcUrl}\n";
@file_get_contents($svcUrl);
}
sleep(5);
}
if (!$tableExists()) {
fprintf(STDERR, "[oidc-config] FATAL: wp_options not present after 5 min\n");
exit(1);
}
echo "[oidc-config] wp_options present.\n";
// Build the OIDC settings array. Field names match the
// openid-connect-generic plugin's option schema (see
// upstream openid-connect-generic-settings.php).
$oidcSettings = [
'login_type' => 'auto', // auto-redirect to Keycloak
'client_id' => $cid,
'client_secret' => $csecret,
'scope' => 'openid email profile',
'endpoint_login' => rtrim($issuer, '/') . '/protocol/openid-connect/auth',
'endpoint_userinfo' => rtrim($issuer, '/') . '/protocol/openid-connect/userinfo',
'endpoint_token' => rtrim($issuer, '/') . '/protocol/openid-connect/token',
'endpoint_end_session' => rtrim($issuer, '/') . '/protocol/openid-connect/logout',
'identity_key' => 'preferred_username',
'no_sslverify' => 0,
'http_request_timeout' => 5,
'enforce_privacy' => 0,
'alternate_redirect_uri' => 0,
'token_refresh_enable' => 1,
'link_existing_users' => 1,
'create_if_does_not_exist' => 1, // auto-create user on first SSO login
'redirect_user_back' => 1,
'redirect_on_logout' => 1,
'acl_enabled' => 0,
'enable_logging' => 0,
'log_limit' => 1000,
];
$oidcSerialised = serialize($oidcSettings);
$upsert = function($key, $value) use ($pdo) {
// wp_options.option_id is autoincrement; ON CONFLICT
// on option_name handles the upsert. Postgres-flavoured
// INSERT ... ON CONFLICT works because pg4wp uses the
// native wp_options layout.
$stmt = $pdo->prepare(
"INSERT INTO wp_options (option_name, option_value, autoload)
VALUES (:k, :v, 'yes')
ON CONFLICT (option_name) DO UPDATE SET option_value = EXCLUDED.option_value"
);
$stmt->execute([':k' => $key, ':v' => $value]);
};
// 1. OIDC plugin settings.
$upsert('openid_connect_generic_settings', $oidcSerialised);
echo "[oidc-config] openid_connect_generic_settings upserted.\n";
// 2. Activate openid-connect-generic in WP.
$activePlugins = serialize([
'openid-connect-generic/openid-connect-generic.php',
]);
$upsert('active_plugins', $activePlugins);
echo "[oidc-config] active_plugins upserted.\n";
// 3. Default theme — both rows MUST be set (template is
// the parent theme, stylesheet is the active child/parent).
$upsert('template', $theme);
$upsert('stylesheet', $theme);
echo "[oidc-config] default theme set to {$theme}.\n";
// 4. siteurl + home — overwrite the WP install defaults
// so links resolve through the ingress host.
$upsert('siteurl', $siteurl);
$upsert('home', $siteurl);
echo "[oidc-config] siteurl/home set to {$siteurl}.\n";
echo "[oidc-config] all upserts complete.\n";
PHP
php /tmp/oidc-config.php
envFrom: []
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
{{- end }}

View File

@ -0,0 +1,31 @@
{{- if and .Values.wordpress.enabled .Values.persistence.wpContent.enabled }}
{{- /*
Persistent volume for /var/www/html/wp-content. Carries the WordPress
themes, plugins, mu-plugins, and uploaded media across pod restarts and
image upgrades. Bound at first install; the wp-content-bootstrap
initContainer in the Deployment seeds the freshly-bound PVC from the
official image's baked-in wp-content directory (idempotent).
*/}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}-wp-content
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
annotations:
# Helm resource-policy keep — do not delete on `helm uninstall`
# (the PVC carries customer content; operator must explicitly
# delete via `kubectl delete pvc` after data export).
helm.sh/resource-policy: keep
spec:
accessModes:
{{- range .Values.persistence.wpContent.accessModes }}
- {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.wpContent.size | quote }}
{{- if .Values.persistence.wpContent.storageClass }}
storageClassName: {{ .Values.persistence.wpContent.storageClass | quote }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,17 @@
{{- if .Values.wordpress.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "bp-wordpress-tenant.fullname" . }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
selector:
{{- include "bp-wordpress-tenant.selectorLabels" . | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,8 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "bp-wordpress-tenant.serviceAccountName" . }}
labels:
{{- include "bp-wordpress-tenant.labels" . | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# bp-wordpress-tenant observability-toggle integration test (issue #182).
#
# Verifies docs/BLUEPRINT-AUTHORING.md §11.2 (Observability toggles must
# default false): a fresh-Sovereign install of bp-wordpress-tenant must
# NOT render a `monitoring.coreos.com/v1` ServiceMonitor or PodMonitor
# by default — those CRDs ship with kube-prometheus-stack which
# depends on the bootstrap-kit (circular dependency on a fresh
# Sovereign).
#
# Usage: bash tests/observability-toggle.sh [CHART_DIR]
set -euo pipefail
CHART_DIR="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cd "$CHART_DIR"
# Skip helm dep build when charts/ is already vendored (CI populates
# it before this step runs, and re-running on CI without `helm repo
# add` fails). Pattern lifted from bp-cilium tests/observability-toggle.sh.
if [ ! -d charts ] || [ -z "$(ls -A charts 2>/dev/null)" ]; then
helm dependency build >/dev/null
fi
# bp-wordpress-tenant requires several values with no sensible default
# (smeDomain, keycloak.realmURL, keycloak.clientSecretName, adminUser.email);
# we supply minimal stubs so the render proceeds.
COMMON_SET=(
--set "smeDomain=acme.example.local"
--set "keycloak.realmURL=https://auth.acme.example.local/realms/sme"
--set "keycloak.clientSecretName=wordpress-oidc"
--set "adminUser.email=admin@acme.example.local"
)
echo "[observability-toggle] Case 1: default render produces no PodMonitor / ServiceMonitor"
helm template smoke-wp . "${COMMON_SET[@]}" > "$TMP/default.yaml"
if grep -qE "^kind: (PodMonitor|ServiceMonitor)$" "$TMP/default.yaml"; then
echo "FAIL: default render of bp-wordpress-tenant contains a PodMonitor/ServiceMonitor CR." >&2
echo " docs/BLUEPRINT-AUTHORING.md §11.2 forbids this — observability toggles must default false." >&2
grep -nE "^kind: (PodMonitor|ServiceMonitor)$" "$TMP/default.yaml" >&2
exit 1
fi
echo " PASS"
echo "[observability-toggle] Case 2: explicit serviceMonitor.enabled=false renders cleanly"
if ! helm template smoke-wp . "${COMMON_SET[@]}" \
--set "serviceMonitor.enabled=false" \
> "$TMP/off.yaml" 2> "$TMP/off.err"; then
echo "FAIL: explicit-off render failed:" >&2
cat "$TMP/off.err" >&2
exit 1
fi
if grep -qE "^kind: (PodMonitor|ServiceMonitor)$" "$TMP/off.yaml"; then
echo "FAIL: explicit-off render still contains a PodMonitor/ServiceMonitor CR." >&2
exit 1
fi
echo " PASS"
# bp-wordpress-tenant doesn't currently render a ServiceMonitor template
# (it would require a wp-prometheus exporter sidecar — see values.yaml
# comment). The toggle is reserved for future use; this test still
# guarantees the default never produces one.
echo "[observability-toggle] All bp-wordpress-tenant observability-toggle gates green."

View File

@ -0,0 +1,260 @@
# Catalyst Blueprint scratch chart for bp-wordpress-tenant — in-vcluster
# turnkey WordPress per SME tenant, SSO-pre-wired against the SME's
# vcluster-local Keycloak realm.
#
# Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every
# operationally-meaningful value (image tag, ingress host, Keycloak realm
# URL, CNPG cluster name, default theme, admin email) is configurable.
# Per-Sovereign cluster overlays in clusters/<sovereign>/.../wordpress/
# may override any of these without rebuilding the Blueprint OCI artifact.
#
# Image-pull strategy
# ───────────────────
# `global.imageRegistry` (rendered as a prefix on `image.repository`)
# routes pulls through the Sovereign's own Harbor proxy-cache once
# bp-harbor reconciles. Empty default = pull directly from Docker Hub
# (only used during pre-Harbor bootstrap; production overlays always
# set this). See bp-cnpg / bp-keycloak / bp-gitea values.yaml for the
# canonical pattern.
global:
# When set, ALL image pulls in this chart route through this registry.
# Used post-handover when the Sovereign's own Harbor takes over the
# proxy_cache role from contabo's central Harbor. Empty = no rewrite
# (image references use upstream defaults). Per-Sovereign overlays
# wire this at install time. Tracked under #560.
imageRegistry: ""
catalystBlueprint:
upstream:
chart: "" # scratch chart — no upstream Helm chart
version: ""
repo: ""
images:
wordpress: "wordpress"
# ─── SME tenant identity ────────────────────────────────────────────────
# `smeDomain` is the customer's free-subdomain (e.g. acme.<otech-fqdn>)
# OR BYO domain (e.g. acme.com); supplied by the tenant-provisioning
# pipeline. Used to derive the default ingress host. Per
# docs/INVIOLABLE-PRINCIPLES.md #4 the value MUST be passed in at
# install — there is no sensible default.
smeDomain: ""
# ─── WordPress application ──────────────────────────────────────────────
wordpress:
enabled: true
# Solo-tenant minimum — single replica. Per-Sovereign overlays bump
# to 2+ once HA is needed (WordPress is stateless once Postgres + the
# wp-content PVC carry the persistent state; with ReadWriteOnce
# storage classes the PVC pins replicas to 1 — operators that need
# >1 must switch to ReadWriteMany on persistence.wpContent.storageClass).
replicas: 1
image:
# Official WordPress image. SHA-pinned per
# docs/INVIOLABLE-PRINCIPLES.md #4 — the manifest-list digest below
# corresponds to `wordpress:6-php8.3-apache` as published on Docker
# Hub; the human-readable tag is kept alongside for forensics.
repository: wordpress
tag: "6-php8.3-apache"
# Multi-arch manifest-list digest for `wordpress:6-php8.3-apache`
# (verified against registry-1.docker.io 2026-05-04).
digest: "sha256:054e611334390d547c732a38b41b8f42feeb8606a29f0a934deba44e0f17b196"
pullPolicy: IfNotPresent
port: 80
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1
memory: 512Mi
# SecurityContext — the official WordPress image runs Apache as
# `www-data` (UID 33). Keep readOnlyRootFilesystem=false because the
# WP boot script writes `wp-config.php` and Apache touches /run.
podSecurityContext:
fsGroup: 33
containerSecurityContext:
runAsNonRoot: true
runAsUser: 33
runAsGroup: 33
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# Liveness / readiness — WordPress's default `/wp-admin/install.php`
# responds 200/302 once the Apache + PHP stack is up. We hit `/`
# which redirects to `/wp-admin/install.php` on first boot and to
# the homepage afterwards; either way Apache returns < 500.
probes:
liveness:
path: /
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readiness:
path: /
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
# ─── Database (bp-cnpg in tenant namespace) ─────────────────────────────
# Per the bp-gitea / bp-harbor pattern (templates/cnpg-cluster.yaml):
# this chart provisions a dedicated `Cluster.postgresql.cnpg.io` for
# WordPress. The CNPG-emitted `<cluster>-app` Secret is mirrored into
# `wordpress-database-secret` by reflector + a post-install sync Job
# so the canonical `password` key is always populated before the
# WordPress Pod starts.
#
# The pattern matches what bp-gitea uses; see
# platform/gitea/chart/templates/database-secret-sync-job.yaml for
# the rationale on Reflector vs. post-install Job.
database:
# CNPG Cluster name & topology — operator-overridable via cluster
# overlay. Defaults to a tenant-isolated cluster sized for SME usage.
cnpgClusterName: "wordpress-db"
cluster:
enabled: true
# CNPG namespace defaults to .Release.Namespace (tenant ns) so the
# Cluster lives next to WordPress. Operator may move it to a
# shared `cnpg-tenants` namespace via overlay.
namespace: ""
instances: 1
pgVersion: "16"
database: "wordpress"
owner: "wordpress"
storageSize: "10Gi"
storageClass: "local-path"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# The reflector-managed wrapper Secret WordPress reads. Always carries
# at minimum the `password` key (mirrored from `<cluster>-app`).
secretName: "wordpress-database-secret"
# ─── SSO via SME-vcluster Keycloak (openid-connect-generic plugin) ──────
# The plugin is pre-installed by the WordPress entrypoint (initContainer)
# from the wordpress.org plugin registry, then registered + configured
# at first install via templates/oidc-config-job.yaml so the SME admin
# never sees the WP setup wizard.
keycloak:
# Discovery endpoint of the SME's realm — operator-supplied (no
# default per docs/INVIOLABLE-PRINCIPLES.md #4). Example shape:
# https://auth.acme.<otech-fqdn>/realms/sme
realmURL: ""
# OIDC client registered in the SME realm — provisioned by the
# tenant-provisioning pipeline (#804) before this chart installs.
clientID: "wordpress"
# ExternalSecret name carrying the OIDC client secret. Required
# key: client-secret. Provisioned by the tenant-provisioning
# pipeline at the same time as the Keycloak client registration.
clientSecretName: "wordpress-oidc"
# Keycloak group → WP role mapping. Default mirrors the SME unified-
# RBAC tier names. Operator may extend per their RBAC posture.
roleMapping:
administrator: "administrator"
editor: "editor"
author: "author"
contributor: "contributor"
subscriber: "subscriber"
# ─── First-install admin user (created via SSO mapping) ─────────────────
# The admin-user-job pre-seeds a wp_users row with `user_login = email`
# and the SSO email mapping so the SME admin's first Keycloak login
# logs them in as the WP administrator without seeing the install wizard.
adminUser:
# Operator-supplied, e.g. `admin@acme.com`. Same email as the SME
# admin's Keycloak account — that's how openid-connect-generic
# matches the Keycloak `email` claim back to a wp_user.
email: ""
# Display name shown in the WP admin bar. Defaults to the local-part
# of the email (job-side) if left empty.
displayName: ""
# ─── Default theme ──────────────────────────────────────────────────────
# The theme slug installed + activated by oidc-config-job. Operator-
# overridable. Default ships the latest WordPress yearly theme.
defaultTheme: "twentytwentyfive"
# ─── Persistence ────────────────────────────────────────────────────────
# /var/www/html/wp-content carries themes, plugins, uploads. Persists
# across pod restarts and image upgrades. PVC bound at first install.
persistence:
wpContent:
enabled: true
size: "10Gi"
storageClass: "local-path"
accessModes:
- ReadWriteOnce
# ─── Service ────────────────────────────────────────────────────────────
service:
type: ClusterIP
port: 80
targetPort: 80
# ─── ServiceAccount ─────────────────────────────────────────────────────
serviceAccount:
create: true
name: ""
# ─── Ingress + cert-manager TLS ─────────────────────────────────────────
# Default host is `wordpress.<smeDomain>`; operator may override
# `ingress.host` directly when BYO domain shapes differ.
ingress:
enabled: true
className: "traefik"
# Empty string → templates derive `wordpress.{{ .Values.smeDomain }}`.
# Operator may set explicitly when BYO requires a non-derived host.
host: ""
tls:
enabled: true
issuer: "letsencrypt-prod" # cert-manager ClusterIssuer
secretName: "wordpress-tls"
annotations: {}
# ─── NetworkPolicy ──────────────────────────────────────────────────────
# Restrict egress to: cnpg cluster (Postgres :5432), Keycloak realm
# (HTTPS :443 — discovery + token endpoints), DNS (kube-dns :53),
# and ingress traffic (gateway namespace). External egress is allowed
# on :443 so WordPress can fetch the `openid-connect-generic` plugin
# zip + theme from wordpress.org during initContainer boot; the
# cluster's egress-gateway enforces destination policy globally.
networkPolicy:
enabled: true
ingress:
fromNamespaceLabels:
kubernetes.io/metadata.name: traefik
egress:
# bp-cnpg — defaults; namespace label may be overridden if the
# tenant cluster lives in a non-default cnpg namespace.
cnpgNamespaceLabel: "" # empty → match Release.Namespace
cnpgPort: 5432
# bp-keycloak — same as cnpg, defaults to .Release.Namespace
# because the tenant Keycloak runs in the SME vcluster's keycloak
# namespace; operator overrides if it lives elsewhere.
keycloakNamespaceLabel: "keycloak"
keycloakPort: 8443
# ─── ServiceMonitor ─────────────────────────────────────────────────────
# Default false per docs/BLUEPRINT-AUTHORING.md §11.2 (Observability
# toggles must default false). Operator opts in via per-cluster overlay
# once kube-prometheus-stack reconciles. WordPress itself doesn't expose
# /metrics — operator must layer a wp-prometheus exporter plugin or
# nginx/php-fpm sidecar before enabling this.
serviceMonitor:
enabled: false
interval: "30s"
scrapeTimeout: "10s"
path: "/metrics"
labels: {}