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. Choose between secretName and credentials and name the users and grants each one 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, the replication user, and — in credentials mode — the app, read-only, monitor and backup users, each with a fixed grant set.
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: two modes, and they are mutually exclusive

spec.secretName and spec.credentials are the only two ways to give the operator a way in, and a CEL rule refuses an object that sets both or neither:

exactly one of secretName or credentials must be set

secretName is the legacy, single-Secret mode. One Secret, and the operator reads a dsn key from it. The playground uses this, and its 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:
  MYSQL_ROOT_PASSWORD: "playground-root-pw"
  MYSQL_ROOT_HOST: "%"                       # root over TCP, not only the unix socket
  MYSQL_REPLICATION_USER: "replicator"
  MYSQL_REPLICATION_PASSWORD: "repl-pw-playground"
  dsn: "root:playground-root-pw@tcp(127.0.0.1:3306)/mysql"

credentials is the per-role mode, and it is what a production group should use. 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
operatorSecret (required)operator and sidecar connectionsALL PRIVILEGES ON *.* WITH GRANT OPTION
appSecretapplication read-writeALL PRIVILEGES ON *.* — no GRANT OPTION, no SUPER
readOnlySecretapplication read-onlySELECT, SHOW VIEW, SHOW DATABASES, PROCESS
monitorSecretPrometheus exporterPROCESS, REPLICATION CLIENT, plus SELECT on performance_schema
backupSecretbackup and restore jobsSELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER, RELOAD, BACKUP_ADMIN, REPLICATION CLIENT

operatorSecret also carries MYSQL_ROOT_PASSWORD, and optionally MYSQL_REPLICATION_USER / MYSQL_REPLICATION_PASSWORD — omit those and the replication user simply reuses the operator’s username and password.

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 REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN, CLONE_ADMIN ON ..
app, readonly, monitor and backup, each created only when its Secret is set, each with the grant list above.
The operator's first poll finds a writable, 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 replication user, no clone plugin, and no app or backup users unless 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 in the Secret does not, by itself, change MySQL. The ALTER USER line only runs on a fresh datadir. Changing the Secret changes what the operator presents; it does not change 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
  sidecarImage: ghcr.io/shipstream/bloodraven-sidecar:1.0.0   # match the operator's release
  credentials:                            # not secretName — per-role users, fixed grants
    operatorSecret: ledger-operator
    appSecret: ledger-app
    readOnlySecret: ledger-readonly
    monitorSecret: ledger-monitor
    backupSecret: ledger-backup
  dns:
    hostname: ledger-db.example.com
    ttl: 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
      role: primary-candidate
      zone: us-east-1a
      lbIP: "10.20.30.11"                 # required unless role is read-only
      taintNodeSelector:                  # required unless role is read-only
        shipstream.io/failover-group.ledger: "true"
        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
      role: primary-candidate
      # … same shape, its own zone, lbIP, node labels and storage class …
    - name: reader
      role: read-only                     # no lbIP 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: read-only 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 shipstream.io/db-readonly-<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.

lbIP and taintNodeSelector are required unless the role is read-only. 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 writable and empty, because a freshly initialised MySQL is writable and nothing has fenced it yet. From Unit 2 you know what the matrix does with more than one writable core site: SPLIT BRAIN. 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 writable, 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, make it the primary, and CLONE INSTANCE the others from it.

The emptiness requirement is the load-bearing part, and Unit 3 already showed you the failure it prevents. A populated cluster can reach the all-writable, no-metadata state by accident: a failover whose status write was rejected, an operator restart that rehydrated a CR with no lastFailoverTarget, and an old primary that respawned writable. 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.

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 pick a credentials mode 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-writable sites is not a split brain.

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=0. From Unit 2 you know the matrix flags SPLIT BRAIN the moment more than one core site is writable. What does the operator actually do?

Show answer

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

The split-brain path is real, and a separate, deliberately conservative check runs ahead of it. isFreshDeploy demands three things of every site at once — writable, no replication metadata, and no data — and only then seeds the group. 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 confuses NoPrimary, which needs every core site read-only, with an all-writable topology. (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 replication user with REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN, CLONE_ADMIN, install the clone plugin, and create any credentials-mode 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 invents a reconcile loop over MySQL users that does not exist for this path. 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. You are in credentials mode with appSecret set. What is the honest answer?

Show answer

Answer: It holds ALL PRIVILEGES ON *.* but without GRANT OPTION and without SUPER, 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 readOnlySecret. Option 4 describes operatorSecret, and conflating the two is the actual finding a review is looking for. (objective 2)

Question 4 of 5

Rotating the password in a credentials-mode Secret changes the password MySQL will accept for that user.

Show answer

Answer: False

It does not. The CREATE USER IF NOT EXISTS / ALTER USER pair lives in the init script, which the MySQL entrypoint runs only on an empty datadir. Changing the Secret changes what the operator and sidecar present; MySQL still expects the old password, and the site drops to unreachable with no obvious cause. What the rotation does do is change the spec hash — credential Secret data is folded into it — so the pods roll, which makes it look like the change took effect. Rotate the password inside MySQL as well, or the restart is the only thing you achieved. (objective 2)

Question 5 of 5

You are reviewing a colleague's first MysqlFailoverGroup before they apply it. It sets both secretName and 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: a CEL rule refuses an object that sets both secretName and credentialsexactly one of secretName or credentials must be set — so 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) both-credentials-modes is a CEL 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. CEL rules validate the object — 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.