Skip to content

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

mysql://app_user:{generated_password}@mysql-{app_id}-rw.paas-apps:3306/app_db

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, NOT root. Single application user scoped to a single application database. This keeps blast-radius from a leaked DATABASE_URL inside 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 -ro Service exists too (read-replica fan-out) but the writable URL points at -rw so 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 rotationensure_mysql_url_secret is 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-apps namespace doesn't yet ship a deny-all default for the mysql Pod selector; cluster-wide NetworkPolicy hardening is a separate task.
  • 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.