Building a group from nothing

An empty namespace, and every line of the manifest a decision you already know how to defend. Credentials, storage, node labels — and which of your mistakes admission catches.

By the end of this topic you can

  1. Write a MysqlFailoverGroup into an empty namespace and say, line by line, which earlier unit decided it
  2. Write spec.credentials and name the users and grants each role produces
  3. Separate the mistakes admission rejects from the ones you discover during an incident

Six units of this course started from a group that already existed. ./playground/setup.sh created a namespace, a Secret, three worker labels, a StorageClass and a MysqlFailoverGroup, and everything since has been about reading and breaking what it built. Now build one yourself, into an empty namespace, with nobody’s script in the way.

The reason to do this last rather than first is that provisioning is where the consequences of Unit 1’s role model, Unit 2’s thresholds and Unit 6’s storage choice all get written down at once. You are not learning field names. You are recording decisions you already know how to defend.

Draw the line first: what is yours, what is the operator’s

The single most common day-0 mistake is building things the operator was going to build, and skipping things it never will.

CompareWho creates what, for one failover group
Yours to createThe operator's
Kubernetes objectsNamespace. StorageClass. Credential Secret(s). Node labels matching each site's taintNodeSelector. The MysqlFailoverGroup itself. A cert-manager Issuer if you want TLS.Per-site Deployment, PVC and ConfigMap. Eight Services for a three-site group. A PodDisruptionBudget. The bloodraven-<group> DNSEndpoint. The init-users ConfigMap.
Inside MySQLNothing, on a fresh datadir. Everything, if you point it at a datadir that already exists.The clone plugin and the operator user, plus the app, read-only, monitor and backup users whose Secrets you set, each with a fixed grant set, and a dedicated replication user if the operator Secret names one.
Outside the clusterexternal-dns, and the DNS zone it writes to. Object storage and its credentials. Prometheus, Grafana, and the alert rules you wrote in Unit 6.One DNSEndpoint object, re-applied every poll. Nothing else leaves the cluster.

Read the bottom-left cell twice. Bloodraven’s own non-goals list from Unit 1 said it does not replace external-dns, cert-manager, Prometheus or your object store, and this is where that stops being a sentence and becomes a work item on somebody’s sprint.

Credentials: one required Secret, four optional ones

spec.credentials is the operator’s only way in, and it is required: an object without it is refused at kubectl apply with spec.credentials: Required value. Releases before v1.7.0 also accepted a single spec.secretName Secret with a dsn key; that mode is gone, and the field is no longer part of the schema.

The playground’s operator Secret is worth reading in full because it is the minimum that works:

apiVersion: v1
kind: Secret
metadata:
  name: mysql-credentials
  namespace: bloodraven-playground
type: Opaque
stringData:
  username: "root"                           # the playground runs the operator account as root
  password: "playground-root-pw"
  MYSQL_ROOT_PASSWORD: "playground-root-pw"
  MYSQL_REPLICATION_USER: "replicator"
  MYSQL_REPLICATION_PASSWORD: "repl-pw-playground"

You supply up to five Secrets, each with username and password, and the operator creates each user with a fixed grant set it will not let you widen:

FieldPurposeGrants the operator issues
operatorSecretName (required)operator and sidecar connectionsALL PRIVILEGES ON *.* WITH GRANT OPTION
appSecretNameapplication read-writeDML and DDL on *.* — no GRANT OPTION, no SUPER or other admin privileges
readOnlySecretNameapplication read-onlySELECT, SHOW VIEW, SHOW DATABASES, PROCESS
monitorSecretNamePrometheus exporterPROCESS, REPLICATION CLIENT, plus SELECT on performance_schema
backupSecretNamebackup and restore jobsSELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER, RELOAD, BACKUP_ADMIN, REPLICATION CLIENT

The operator Secret also carries MYSQL_ROOT_PASSWORD, and optionally MYSQL_REPLICATION_USER / MYSQL_REPLICATION_PASSWORD — omit those and replication simply reuses the operator’s username and password. Name a dedicated replication user and the first-boot script creates it with REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN, CLONE_ADMIN, nothing more.

The grant lists are worth one careful read, because they are the security review answer you will be asked for. The application user cannot grant privileges to anyone. The monitor user cannot read your data. The backup user can take a consistent dump and nothing else. None of that is configurable, and that is the point: a fixed grant set is a claim you can make in a meeting.

The users appear on first boot, and only on first boot

The mechanism is the ordinary MySQL one, and knowing it saves you an outage. The operator renders an init-users ConfigMap and mounts it at /docker-entrypoint-initdb.d. The MySQL entrypoint runs everything in that directory once, on an empty datadir, before the server accepts external connections — and never again.

FlowWhat runs the first time a site's MySQL container starts on an empty PVC
Standard mysql image behaviour: an empty /var/lib/mysql triggers initialisation and the /docker-entrypoint-initdb.d hook.
INSTALL PLUGIN clone SONAME 'mysql_clone.so' — guarded by a COUNT(*) on information_schema.PLUGINS, so it is idempotent.
CREATE USER IF NOT EXISTS + ALTER USER (so a password change is applied), then GRANT ALL PRIVILEGES ON . WITH GRANT OPTION.
app, readonly, monitor and backup, each created only when its Secret is set, each with the grant list above.
mysqld restarts with the read_only=ON it is always started with, and the operator's first poll finds a read-only, empty site. Nothing in this list ever runs again on this PVC.

Two consequences follow, and both are the kind of thing you meet once.

Adopting an existing datadir means the init script does not run. If you point a new group at PVCs that already hold MySQL data, the entrypoint skips initialisation entirely, so there is no clone plugin and no operator user until you create them by hand with exactly the grants above. CLONE_ADMIN and BACKUP_ADMIN are the two people forget, and their absence surfaces much later — as a reclone that will not start, or a backup job that cannot take a consistent dump.

Rotating a password is the operator’s job only for the role users. Change a role Secret’s password and the operator runs ALTER USER on the primary for you. MYSQL_ROOT_PASSWORD and a dedicated replication user’s password are read only by the first-boot script, so changing them in the Secret changes what the operator presents, not what MySQL accepts, and the failure looks like a site going Unreachable for no reason.

The manifest, decision by decision

Here is a production-shaped group written from nothing. Every line is a decision made earlier in this course; the comments say which.

apiVersion: shipstream.io/v1alpha1
kind: MysqlFailoverGroup
metadata:
  name: ledger
  namespace: ledger-db
spec:
  image: mysql:9.7                        # one supported baseline; pin it, never mysql:9
  credentials:                            # required — per-role users, fixed grants
    operatorSecretName: ledger-operator
    appSecretName: ledger-app
    readOnlySecretName: ledger-readonly
    monitorSecretName: ledger-monitor
    backupSecretName: ledger-backup
  dns:
    hostname: ledger-db.example.com
    ttlSeconds: 60                        # the shipped default; the playground's 10 is not
  splitBrainPolicy:
    sitePriorities: [iad, pdx]            # Unit 5: a standing decision about whose writes to discard
  replication:
    maxLagSeconds: 300                    # alerting only — never a promotion gate
    readOnlyMaxLagSeconds: 30             # reader endpoint membership only
  sites:
    - name: iad
      serverID: 101
      role: PrimaryCandidate
      zone: us-east-1a
      loadBalancerIP: "10.20.30.11"                 # required unless role is ReadOnly
      taintNodeSelector:                  # required unless role is ReadOnly
        bloodraven.shipstream.io/failover-group.ledger: "true"
        bloodraven.shipstream.io/site.ledger: iad
      storage:
        storageClassName: fast-ssd-east
        size: 500Gi
      resources:
        requests: { cpu: "2", memory: 8Gi }
        limits:   { cpu: "4", memory: 8Gi }
    - name: pdx
      serverID: 102
      role: PrimaryCandidate
      # … same shape, its own zone, loadBalancerIP, node labels and storage class …
    - name: reader
      serverID: 103
      role: ReadOnly                      # no loadBalancerIP and no taintNodeSelector: never promoted, never tainted
      storage:
        storageClassName: standard-west
        size: 500Gi

Four lines earn a second look.

storageClassName is per site, deliberately. Sites are in different failure domains and often on different hardware. Nothing requires them to match, and a reader on cheaper storage is a legitimate choice — right up until you remember from Unit 6 that a role: ReadOnly site can never source a backup, so cheap reader storage buys you nothing on the recovery path.

resources.limits.memory should equal requests.memory for MySQL. Kubernetes gives a Pod the Guaranteed QoS class only when every container sets equal requests and limits for both CPU and memory, and Guaranteed is the class the kubelet evicts last under node pressure. A primary evicted for memory pressure is an unplanned failover you did not schedule.

taintNodeSelector labels must already be on the nodes. The operator applies the <namespace>.db-readonly.bloodraven.shipstream.io/<group> NoExecute taint from Unit 4 by selecting nodes with these labels. Get the label wrong and nothing errors — the taint is simply applied to nothing, and the eviction half of your failover strategy silently does not exist.

loadBalancerIP and taintNodeSelector are required unless the role is ReadOnly. That is a CEL rule, so you find out at kubectl apply, which is the good case.

What admission catches, and what it does not

Learn this split. It decides which of your mistakes cost you seconds and which cost you an incident.

Rejected at kubectl apply, by the CRD schema and its CEL rules:

Not caught by anything:

Every item in that second list is on the Unit 6 go-live gate for a reason.

Bootstrap: the first poll on an empty cluster

Apply the manifest and every site comes up read-only and empty: the operator starts every mysqld with --read-only=ON, and each sidecar’s startup safety net adds super_read_only because no active site has been named yet. From Unit 2 you know what the matrix does with every core site read-only and none unreachable: NoPrimary, and it refuses to elect. That is not what happens, and the reason is a separate, deliberately conservative check.

isFreshDeploy requires three things of every site at once: it is reachable (ReadOnly, or Writable if its pod predates the boot flag), it has never had replication configured (SHOW REPLICA STATUS returns nothing), and it holds no data — an empty GTID_EXECUTED, cross-checked against user schemas where the probe is available. Only then does the operator seed the group: pick a site by sitePriorities, lift its fence, report it as the active site at once, and CLONE INSTANCE the others from it. Because an all-read-only group produces no further state transitions, the check also runs on every quiet poll while the group sits at NoPrimary. And if the seed’s own sidecar fences it again before the operator’s answer reaches it, the operator re-asserts the seed: it made that site primary itself.

The emptiness requirement is the load-bearing part, and Unit 3 already showed you the failure it prevents. A populated cluster can reach the no-history, no-metadata state by accident: a failover whose status write was rejected, an operator restart that rehydrated a CR with no status.lastFailover.target, and an old primary that respawned (writable on a pod that predates the boot flag, read-only after). The promoted primary’s own RESET REPLICA ALL erased its channel metadata, so metadata absence alone cannot tell that cluster from a fresh one. Treat it as fresh and the operator would seed by priority order and clone the newer side from the stale one, destroying every post-failover write. A site with data is never part of a fresh deploy — full stop.

This is also why a failed clone leaves you stuck. Replication metadata survives on the site that half-succeeded, so isFreshDeploy refuses forever after. The way out is to remove the evidence deliberately — STOP REPLICA; RESET REPLICA ALL; on the stuck site, then restart the operator — and the reason to do it consciously is that you are overriding a safety check, not clearing a glitch.

What a failed clone no longer does is pass for a successful one. A dropped connection is how CLONE INSTANCE ends either way: the recipient restarts when the clone succeeds, and the session dies when it is killed after MySQL has already dropped the recipient’s data. So before it configures replication the operator reads the recipient’s performance_schema.clone_status and requires a new Completed row and a non-empty GTID_EXECUTED. Anything else fails the bootstrap — the Bootstrapping condition goes to False/BootstrapFailed, Degraded goes to True/BootstrapFailed, and the operator logs bootstrap failed — and the recipient is fenced again. Bootstrapping is True only while a clone runs, and False/NotBootstrapping both before any clone and after a successful one, so “the clone finished” means False/NotBootstrapping with a lastTransitionTime after you asked and the recipient’s replication reading Healthy. Throughout the clone the recipient keeps read_only=ON; only super_read_only is cleared, because CLONE INSTANCE refuses to run under it.

Where this leaves you

You can write a MysqlFailoverGroup into an empty namespace and say, for every line, which earlier unit decided it. You can write spec.credentials and recite the grants each user gets. You can say which of your mistakes the API server will catch and which will wait for an incident, and you can explain why a group of freshly created, all read-only sites is not stuck at NoPrimary.

One line in that manifest has been quietly deferred since Unit 6, where the CRD refused to enable encryption without it. spec.tls is next.

Flashcards

What you have to create yourself for a new failover group

Namespace, StorageClass, credential Secret(s), node labels matching each site's taintNodeSelector, the MysqlFailoverGroup itself, and a cert-manager Issuer if you want TLS. Also external-dns, object storage and Prometheus — Bloodraven's non-goals list said it replaces none of them.

1 / 13

Quiz

Question 1 of 5

You apply a brand-new three-site MysqlFailoverGroup into an empty namespace. All three MySQL pods come up, and every one of them reports read_only=1. From Unit 2 you know the matrix answers NoPrimary and refuses to elect when every core site is read-only and none is unreachable. What does the operator actually do?

Show answer

Answer: Nothing alarming: isFreshDeploy finds every site reachable, replication never configured and no data anywhere, so it seeds a primary by sitePriorities, lifts its fence and clones the others from it

The NoPrimary refusal is real, and a separate, deliberately conservative check runs ahead of it. isFreshDeploy demands three things of every site at once — reachable, no replication metadata, and no data — and only then seeds the group; read-only or writable is no part of the test, because mysqld boots read_only=ON. Option 2 describes what would happen without that check and would make every new group a manual procedure. Option 3 is the dangerous misreading: priority order does decide the seed, but only after emptiness has been proved, and skipping that proof is exactly how a restart-amnesia cluster gets cloned backwards. Option 4 has it backwards: a sidecar clears its fence only when the operator names its site, and no site is named until the operator seeds one — the operator lifts the seed's fence itself. (objective 1)

Question 2 of 5

Your platform team hands you three PVCs that already contain a working MySQL dataset, and asks you to put a MysqlFailoverGroup in front of them. What do you have to do that a green-field install would have done for you?

Show answer

Answer: Create the operator user with ALL PRIVILEGES ON *.* WITH GRANT OPTION (and any dedicated replication user with REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN, CLONE_ADMIN), install the clone plugin, and create the role users by hand — the init script only runs on an empty datadir

The init-users ConfigMap is mounted at /docker-entrypoint-initdb.d, which the MySQL entrypoint runs only when it is initialising an empty datadir. Adopt a populated one and none of it runs. CLONE_ADMIN and BACKUP_ADMIN are the two grants people miss, and their absence surfaces much later as a reclone that will not start or a backup job that cannot take a consistent dump. Option 2 misremembers the entrypoint contract. Option 3 overstates the reconcile: the operator re-applies the role users only when their Secrets change, never on every poll, and only once it can log in as the operator user or as root. Option 4 is over-cautious: adoption works, it just moves the user setup onto you. (objectives 1, 2)

Question 3 of 5

A security review asks which MySQL user your application connects as, and what it could do if the credentials leaked. appSecretName is set. What is the honest answer?

Show answer

Answer: It holds DML and DDL on *.* but no GRANT OPTION, SUPER or other admin privileges, so it can read and write everything and cannot create users, grant privileges or bypass super_read_only

The grant sets are fixed by the operator, not configurable, and that is precisely what makes them answerable. The app user is deliberately full-privilege-minus-escalation: it cannot grant, and without SUPER/CONNECTION_ADMIN it cannot write through a super_read_only fence. Option 2 is the answer people expect and is wrong — there is no grant list in the CRD. Option 3 describes readOnlySecretName. Option 4 describes operatorSecretName, and conflating the two is the actual finding a review is looking for. (objective 2)

Question 4 of 5

Rotating the password in a role Secret changes the password MySQL will accept for that user.

Show answer

Answer: True

It does, for the role users. The operator hashes the role Secrets, and when the hash changes it connects to the primary and runs CREATE USER IF NOT EXISTS / ALTER USER … IDENTIFIED BY with the Secret's exact bytes; replication carries the change to the other sites. If the rotated password is the operator's own, it connects as root with MYSQL_ROOT_PASSWORD to apply it. The spec hash changes too, so the pods roll and the sidecar re-reads its files. Two passwords are not covered: MYSQL_ROOT_PASSWORD itself and a dedicated replication user's, which only the first-boot script reads — change those inside MySQL as well. (objective 2)

Question 5 of 5

You are reviewing a colleague's first MysqlFailoverGroup before they apply it. It leaves out spec.credentials; gives one site a taintNodeSelector naming labels no node carries; lists a storageClassName that does not exist in the cluster; and sets image: mysql:9. Which of these will they find out about from kubectl apply, and which will they find out about later — and for the later ones, how?

Show answer

Answer:

Only the first is caught at apply time: the schema requires spec.credentials, so the API server refuses the object (spec.credentials: Required value) and the apply fails immediately and costs them seconds. The other three are admitted. The bogus taintNodeSelector fails silently and completely: the operator selects nodes by those labels, matches nothing, and applies the NoExecute taint to no node, so the eviction half of their failover strategy does not exist and nothing anywhere says so. The missing storageClassName shows up as PVCs stuck Pending and pods stuck ContainerCreating — visible within a minute, if they look. And image: mysql:9 is admitted because there is no version admission check of any kind; a floating tag can drift them onto an unsupported MySQL between pod restarts, and it surfaces as MySQL pod failures rather than an operator error.

A full-credit answer shows: A strong answer separates the four correctly: (1) the missing spec.credentials is a schema rejection at apply; (2) the bad taintNodeSelector is silent and permanent, and names the consequence — no taint is applied, so nothing is evicted at the demoted site; (3) the missing StorageClass is visible as Pending PVCs; (4) the floating tag is admitted because no version admission check exists, and the risk is drifting onto an unsupported version between restarts. Credit an answer that notes the general rule: admission catches structural mistakes about the object, and catches nothing about the cluster the object refers to.

The split is the whole point of the topic. The schema and its CEL rules validate the object — required fields, uniqueness, mutual exclusion, required fields per role, the interval relationships on spec.sidecar — and they are cheap and immediate. Nothing validates the object against the cluster: not node labels, not storage classes, not image tags, not whether the Secret's credentials are ones MySQL actually has. The taintNodeSelector case is the one worth remembering, because unlike a Pending PVC it produces no symptom at all until a failover that should have evicted an application pod quietly does not. (objective 3)

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.