Skip to content

MySQL and MariaDB driver - #9

Merged
solsson merged 15 commits into
mainfrom
mysql-driver
Oct 2, 2026
Merged

solsson merged 15 commits into
mainfrom
mysql-driver

Conversation

@solsson

@solsson solsson commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

No description provided.

Yolean gle-qa and others added 15 commits October 1, 2026 20:27
A driver that creates one backend principal per access (the mysql
driver's database users) needs a stable, unique name for it, and
GrantRequest carried only the Buckety side. The access's namespace
and name are both immutable for its lifetime and unique together,
so they are what such a driver derives the principal from; drivers
with shared principals ignore the new fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Buckety is a database (CREATE DATABASE IF NOT EXISTS, optional
characterSet/collation), a BucketyAccess is a user of its own with
the role's privileges on that one database: Reader gets SELECT,
ReadWrite the data changes plus the DDL schema migrations need.
Writer is refused rather than given a guessed grant set. Delete
drops the database; users are dropped with their access under every
retention policy.

Security model. The controller's account needs CREATE USER on *.*,
which neither server can scope by name and which also allows
ALTER/RENAME/DROP USER on every account. The backend's namePrefix
is therefore the boundary, enforced in one place (checkManaged):
every database and user name, including principals read back from
status, must start with it, be lowercase [a-z0-9_-] within the
limits, and not be a system name; the prefix must end in "_", stay
off system names, and exclude the admin account. Grants use the
database name with LIKE wildcards escaped (an unescaped "_" in
b_t1_orders would also match b_t1xorders). Account names and
passwords travel as ? arguments with client-side interpolation,
because CREATE USER/GRANT are not parameterisable server-side;
identifiers are validated and backtick-quoted; privilege names read
from the server are checked before they reach REVOKE.

Names. Databases are the resolved spec.name, which must carry the
prefix, so status.backendResourceName and the Secret's database key
are the real name. Users are <prefix><namespace>_<name>, or a
hashed form when that does not fit MySQL 8's 32-character limit
(MariaDB allows 80; one scheme keeps backends portable). Kubernetes
names have no "_", so the two forms cannot collide.

Drift without reading the mysql schema. The admin account cannot see
other accounts or their grants, so the driver logs in as each user
with the Secret's password: success reads the user's own
SCHEMA_PRIVILEGES and aligns grants (revoking extras and GRANT
OPTION), writing nothing when nothing differs, which matters on
Galera where account changes replicate as TOI. A refused login means
a missing user or a changed password: CREATE or ALTER USER, grant,
and verify with a short retry for Galera lag. Passwords are
generated from crypto/rand at the first grant and read back from the
Secret afterwards, so a password written into the Secret is applied
and deleting the Secret rotates it. Errors from statements carrying
a password are redacted; no DSN strings are built.

Tested with the env-gated integration test against MariaDB 10.11.14
and MySQL 8.0.46 run from distro packages in userspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
examples/mysql/bootstrap creates the controller's account where the
instance is created: an initdb component adds buckety-account.sh to
/docker-entrypoint-initdb.d and mounts the Secret it reads the
password from, so no password exists in source. The image entrypoint
runs it once, on the first boot of an empty data directory, with
root on the local socket - the only way in when root may connect
only from localhost, as in kubernetes-mysql-cluster. The component
targets that project's StatefulSet mariadb, so any variant takes it;
the example pins variants/scale-1. The script runs in a subshell so
the entrypoint may source or execute it, validates the password with
the driver's rule, passes it on stdin, and is idempotent for manual
runs against existing instances.

e2e: a single ephemeral MariaDB, shaped like kubernetes-mysql-cluster
so the same component applies, with the password generated by
mariadb-secret.sh into both namespaces that need it (a pod mounts
Secrets only from its own namespace). The controller env is optional
so clusters without it still start. Scenarios: happy-path (database
with charset, ReadWrite user running DDL from a Job) and
multi-consumer (Reader denied INSERT/CREATE by the server, Writer
refused with GrantFailed and no Secret, deletion blocked by
accesses). Both are unrun: no k3d/docker where they were written.

secret_has_keys printed the whole Secret on a missing key; it now
prints key names only, so a failing run does not put credentials in
CI logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC gains the driver in the driver table, the Secret output
section with the prefix boundary as a contract-level property, the
database notion of empty for adoption, the CI matrix row, and the
scoping exception in the non-goals. README covers the backend config
and the CREATE USER caveat for cluster maintainers. docs/mysql.md
carries the detail: the account's grants, why CREATE USER makes the
prefix the security boundary and that sensitive accounts belong on
an instance without a backend, naming rules, roles, drift detection
by login, SQL construction, MySQL 8 notes, and the design
decisions, listed for review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- userHost must be lowercase. Servers store hosts lowercased, so an
  uppercase userHost made every login check read as shadowed (an
  ALTER USER and grants on every pass) and a case-only change would
  have revoked the live account as the "old" principal.
- An error from CREATE/ALTER USER keeps only the server's error
  number, with a hint for the policy and privilege cases. A parse
  error's message quotes the statement and can cut it mid-password,
  which whole-string redaction cannot catch.
- The cleanup DROP USER after a failed pass runs detached from the
  request context, which may be what failed.
- A line break in adminPassword is a startup error, and the
  bootstrap script refuses a password file with a trailing newline:
  the controller gets the Secret verbatim through env, while $(cat)
  stripped the newline, so the two would never match.
- The e2e secret copy uses create/replace instead of apply, which
  would also store the data in last-applied-configuration.
- docs/mysql.md: cleartext passwords without tls, grants the login
  check cannot see, the fixed namePrefix and how to retire one,
  trust in status within the prefix, ReadWrite's DROP on its own
  database, Secret-edit churn on Galera, SYSTEM_USER on MySQL 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MySQL 8.4 refuses REVOKE of a privilege the account does not hold
(error 1141), and a REVOKE naming several privileges is atomic. The
first grant of a Reader revoked the rest of the ReadWrite set in one
statement, so on 8.4 a Reader access could never be granted; MariaDB
and MySQL 8.0 accept such a REVOKE silently.

A user created in the same pass holds nothing, so the first grant
now revokes nothing. A re-keyed user (password from a deleted or
edited Secret) still has unknown grants: the rest of the managed set
is revoked one privilege at a time, with "no such grant" as the
expected answer for each one it does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A gcs test comment pointed at a requirements document in a private
repository; it now says what the case is. Two docs/mysql.md
paragraphs are reflowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/integration/mysql.sh runs TestIntegration against MariaDB 10.11
and 11.4 and MySQL 8.0 and 8.4, one container each. The controller
account is created the way a cluster creates it: the bootstrap
script from examples/mysql runs from /docker-entrypoint-initdb.d as
root on the socket, root only from localhost, so the script is
tested against every image too. The e2e workflow runs the same
script as its own job, and publish waits for it.

BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto takes the address the server
sees the test connect from, so the userHost-change step runs there
as well. A new step rotates a Reader's password by deleting its
Secret, the re-keyed path whose grants are unknown; on MySQL 8.4
that path needs one REVOKE per privilege.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two findings from a second review, of how the driver meets the
reconcile loops:

- Changing an existing access's role to Writer was refused before
  anything reached the server, so the access reported GrantFailed
  while its user kept ReadWrite's grants and its Secret kept
  working. A role without a grant set now drops the access's user
  (DROP USER IF EXISTS, one statement per failing reconcile).
  Changing the role back recreates the user with the password
  still in the Secret.

- DROP DATABASE waits for open transactions on the database, and
  consumer sessions outlive DROP USER. The server's default wait
  is a day (MariaDB) or a year (MySQL), while the client gives up
  after 60s, so every retry queued another DROP whose pending lock
  blocks new queries on that database. The controller's sessions
  now set lock_wait_timeout to 50s, below the client timeout.

The integration test covers both against every server image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- an access changed to Writer loses its user;
- user names are unique within a cluster, not across clusters:
  one controller per server and namePrefix;
- a generated password is verified on the next re-check, not in
  the pass that sets it, and the Galera retry is about four
  seconds;
- grant changes reach an open session when it next selects the
  database; DROP DATABASE gives up after lock_wait_timeout;
- more ways a user ends up recorded by no access, and that a
  shadowing account costs replicated statements on every re-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ParameterDrift pauses re-checks until the Buckety changes, by the
controller's design for drift that needs a human. The mysql message
told users to ALTER DATABASE out of band, which alone leaves the
Buckety at Ready=False indefinitely; e2e found it. The message and
docs/mysql.md now say to annotate the Buckety afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mysql had happy-path and multi-consumer only (both now run on a
local k3d against the MariaDB backing service). Added:

- the shared bodies: backend-stickiness, driver-version,
  misconfigured-startup (plus an adminUser inside namePrefix) and
  scaled-to-zero;
- oob-drift: DROP USER, extra grants with GRANT OPTION, DROP
  DATABASE and a changed character set, each made as root; password
  rotation by deleting the Secret, and a password written into it;
- adoption: AdoptEmpty refuses a database with a table, adopts an
  empty one, Adopt claims the first, and adopted databases survive
  retentionPolicy=Delete;
- retention-policy: Retain keeps the rows and drops the user,
  Delete drops both;
- parameter-mutation: immutable characterSet/collation, unknown
  keys, utf8, a name outside namePrefix, access parameters.

lib.sh gains mysql_root (root on the pod's socket), mysql_login
(a Secret's credentials over TCP, password on stdin) and
wait_until. backend_stickiness_scenario deletes sticky-new while
its backend is still registered, and accepts the block showing on
the implicit access: an access holding a principal of its own (a
mysql user) cannot be revoked once its backend is gone, and left
the namespace stuck terminating. kadm, s3 and gcs pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go.mod requires go 1.27.0 and names toolchain go1.27.1 explicitly
(a toolchain line equal to the go line is dropped by go mod tidy).
CI's setup-go reads go-version-file: go.mod and installs the
toolchain directive's version, so CI rebuilds the pinned image with
the same compiler as scripts/bump-release.sh, which refuses any
other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docker.io/minio/minio no longer serves anonymous pulls, so the
backing service never became ready and CI failed before any
scenario ran. ghcr.io/yolean/minio mirrors the releases; its newest
is RELEASE.2025-07-18T21-56-31Z, pinned by digest like the other
backing-service images. All s3 scenarios pass against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@solsson
solsson merged commit ac3f212 into main Oct 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant