MySQL Addon¶
paas ships a managed MySQL addon backed by the Oracle MySQL
Operator
(mysql.oracle.com/v2). Each app gets a dedicated InnoDBCluster
with the app_user / app_db convention, a generated password,
and a DATABASE_URL Secret pre-mounted at deploy time.
At a glance¶
| Capability | Default | Where to configure |
|---|---|---|
| Engine | MySQL 8.4 LTS (Oracle InnoDBCluster CR) | version in the create payload |
| Versions accepted | 8.0, 8.4, 8.4.0 |
version |
| Charset | utf8mb4 (4-byte) — emoji safe |
pinned in mycnf, not configurable |
| Collation | utf8mb4_unicode_ci |
pinned |
| Plans | free / standard / pro |
plan in the create payload |
| Connection user | app_user (NOT root) |
not configurable — security boundary |
| Database | app_db |
not configurable |
| Cluster Service | mysql-{app_id}-rw (primary) and -ro (read replica) |
derived from app_id |
| Connection URL | injected as DATABASE_URL env var |
via mysql-{app_id}-url Secret |
Lifecycle¶
flowchart LR
A["POST /v1/apps/{id}/addons<br/>{type:'mysql', plan, version}"]
A --> B["addons.rs::create_addon_generic"]
B --> C["credentials::generate_password()"]
C --> D["ensure_mysql_url_secret<br/>mysql-{app}-url Secret"]
D --> E["ensure_innodbcluster<br/>InnoDBCluster CR Patch::Apply"]
E --> F["MySQL Operator<br/>provisions Pods + Services"]
F --> G["mysql-{app}-rw / -ro Services"]
H["dashboard polls<br/>poll_addon_status mysql branch"]
H --> I["get_innodbcluster<br/>parse_mysql_status"]
I -- "ONLINE" --> J["app_addons.status = 'Ready'<br/>ready_at stamped"]
I -- "OFFLINE/ERROR" --> K["status = 'Failed'"]
I -- "_/missing" --> L["status = 'Creating'"]
Plans¶
| Plan | Instances | Memory (per pod) | Storage | Use case |
|---|---|---|---|---|
free |
1 | 256Mi |
1Gi |
dev / preview environments, low-traffic side projects |
standard |
1 | 1Gi |
10Gi |
typical production app |
pro |
3 | 4Gi |
50Gi |
HA primary + 2 replicas, larger working set |
The mapping lives in
paas_database::mysql_provisioner::mysql_plan_config and
mirrors the CNPG / Valkey paliers so the dashboard's plan picker
stays semantically aligned across addon types. Unknown plans
reject at the API layer with HTTP 422; passing a plan the
endpoint validator accepts (free/standard/pro) is sufficient.
utf8mb4 is mandatory — never the legacy 3-byte utf8
The InnoDBCluster spec pins character-set-server=utf8mb4
and collation-server=utf8mb4_unicode_ci in mycnf. The
legacy MySQL utf8 encoding is 3-byte only and silently
truncates 4-byte glyphs (emoji, some CJK characters) —
irrecoverable data loss. paas refuses to provision with
anything else; an anti-regression test
(build_spec_sets_utf8mb4_charset) pins the constraint at
workspace test time.
DATABASE_URL format¶
Materialised in the K8s Secret named mysql-{app_id}-url (key:
DATABASE_URL). The dashboard's paas config:set integration
mounts it into the app pod's environment automatically — your
code reads process.env.DATABASE_URL (Node.js), os.environ['DATABASE_URL']
(Python), std::env::var("DATABASE_URL") (Rust), etc.
Components:
app_user— fixed PaaS convention, NOTroot. Single application user scoped to a single application database. This keeps blast-radius from a leakedDATABASE_URLinside the tenant's own data.{generated_password}—paas_database::credentials::generate_password()emits a hex-uuid v4 derivative with ≥ 60 chars of entropy. Never hardcoded, never derivable from the addon UUID.mysql-{app_id}-rw— the primary-instance Service the Operator emits in front of the writable instance. The-roService exists too (read-replica fan-out) but the writable URL points at-rwso writes never silently land on a read-only target.paas-apps— the namespace where every tenant addon lives.3306— the standard MySQL wire port.app_db— the single application database the addon ships with.
Versions¶
8.0 (legacy) and 8.4 (LTS) are the accepted values for
version. The default is 8.4.0 — anything else (or None)
falls back to MYSQL_DEFAULT_VERSION so a malformed client
payload can't ship a poisoned dbVersion to the operator.
// paas_database::mysql_provisioner
match version {
Some("8.0") => "8.0.0",
Some("8.4") | Some("8.4.0") => "8.4.0",
Some(v) if v.starts_with("8.0.") || v.starts_with("8.4.") => v,
_ => MYSQL_DEFAULT_VERSION,
}
Tests de validation (DoD)¶
APP_ID=... # your app's UUID
TOKEN=$(paas auth print-token)
PAAS_URL=https://runtime.di2amp.com
# 1. Create the MySQL addon
curl -sk -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"addon_type":"mysql","plan":"standard","version":"8.4"}' \
$PAAS_URL/v1/apps/$APP_ID/addons | jq .
# Expect:
# { "data": { "addon_type": "mysql", "plan": "standard", "version": "8.4", "status": "creating" } }
# 2. Poll until Ready (≤ 5 min after operator install)
for i in 1 2 3 4 5 6 7 8 9 10; do
STATUS=$(kubectl -n paas-apps get innodbcluster mysql-$APP_ID -o jsonpath='{.status.cluster.status}' 2>/dev/null)
echo "tick $i: $STATUS"
[ "$STATUS" = "ONLINE" ] && break
sleep 30
done
# 3. Verify DATABASE_URL is mounted on the app pod
kubectl -n paas-apps exec deployment/app-$APP_ID-web -- env | grep DATABASE_URL
# Expect:
# DATABASE_URL=mysql://app_user:...@mysql-{APP_ID}-rw.paas-apps:3306/app_db
# 4. Connect via the mysql CLI from inside the cluster
PWD=$(kubectl -n paas-apps get secret mysql-$APP_ID-url \
-o jsonpath='{.data.DATABASE_URL}' | base64 -d \
| sed -E 's|mysql://app_user:([^@]+)@.*|\1|')
kubectl -n paas-apps run mysql-cli --rm -it --image=mysql:8.4 --restart=Never -- \
mysql -h mysql-$APP_ID-rw.paas-apps -P 3306 -u app_user -p"$PWD" app_db -e 'SELECT NOW();'
# 5. Verify charset is utf8mb4 (not legacy utf8)
kubectl -n paas-apps run mysql-cli --rm -it --image=mysql:8.4 --restart=Never -- \
mysql -h mysql-$APP_ID-rw.paas-apps -u app_user -p"$PWD" app_db \
-e 'SELECT @@character_set_server, @@collation_server;'
# Expect:
# +------------------------+----------------------+
# | @@character_set_server | @@collation_server |
# +------------------------+----------------------+
# | utf8mb4 | utf8mb4_unicode_ci |
# +------------------------+----------------------+
Implementation pointers¶
| Concern | File |
|---|---|
| Plan → resources mapping | crates/database/src/mysql_provisioner.rs::mysql_plan_config |
| RFC-1123 cluster name | crates/database/src/mysql_provisioner.rs::mysql_cluster_name + sanitize_dns_label |
| InnoDBCluster spec builder | crates/database/src/mysql_provisioner.rs::build_innodbcluster_spec |
| DATABASE_URL formatter | crates/database/src/mysql_provisioner.rs::mysql_database_url |
| Secret materialiser | crates/database/src/mysql_provisioner.rs::ensure_mysql_url_secret |
| CR get-or-create | crates/database/src/mysql_provisioner.rs::ensure_innodbcluster |
| Status projection | crates/database/src/mysql_provisioner.rs::parse_mysql_status |
| Route handler | crates/control-plane/src/routes/addons.rs::create_addon_generic (mysql branch) |
| Polling loop | crates/control-plane/src/routes/addons.rs::poll_addon_status (mysql branch) |
| Password generator | crates/database/src/credentials.rs::generate_password |
Cluster pre-requisites¶
The Oracle MySQL Operator must be installed before the addon can be provisioned. paas's hot-fix runbook:
helm repo add mysql-operator https://mysql.github.io/mysql-operator/
helm repo update mysql-operator
helm install mysql-operator mysql-operator/mysql-operator \
--namespace mysql-operator --create-namespace
# K3s only: the operator hangs without an explicit cluster domain.
kubectl set env -n mysql-operator deploy/mysql-operator \
MYSQL_OPERATOR_K8S_CLUSTER_DOMAIN=cluster.local
# Sanity:
kubectl get crd | grep mysql.oracle.com
# Expect 3 CRDs: innodbclusters / mysqlbackups / mysqlclustersetfailovers
kubectl -n mysql-operator get pods
# Expect: mysql-operator-... 1/1 Running
This pre-req is documented in
project/paas-runtime/HOTFIXES.md so any operator running the
runbook gets the correct env-var fix on first try.
Limits (cycle 2)¶
- Backup (sub-brique 41f) — mysql-shell + binlog → S3 OVH is out of scope for cycle 2; tracked as a future task.
- Password rotation —
ensure_mysql_url_secretis idempotent and re-applies cleanly with a fresh password, but the rotation flow itself (re-issue + propagate to running pods) is cycle 3+ work. - NetworkPolicy — the
paas-appsnamespace doesn't yet ship a deny-all default for the mysql Pod selector; cluster-wide NetworkPolicy hardening is a separate task.
Related concepts¶
- Add-ons — the umbrella addon flow that mysql, postgres, valkey, and opensearch all hang off.
- Postgres Addon — sister addon backed by CNPG.
- Valkey Addon — sister addon for Redis-wire caching.