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
- Write a
MysqlFailoverGroupinto an empty namespace and say, line by line, which earlier unit decided it - Write
spec.credentialsand name the users and grants each role produces - 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.
| Yours to create | The operator's | |
|---|---|---|
| Kubernetes objects | Namespace. 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 MySQL | Nothing, 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 cluster | external-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:
| Field | Purpose | Grants the operator issues |
|---|---|---|
operatorSecretName (required) | operator and sidecar connections | ALL PRIVILEGES ON *.* WITH GRANT OPTION |
appSecretName | application read-write | DML and DDL on *.* — no GRANT OPTION, no SUPER or other admin privileges |
readOnlySecretName | application read-only | SELECT, SHOW VIEW, SHOW DATABASES, PROCESS |
monitorSecretName | Prometheus exporter | PROCESS, REPLICATION CLIENT, plus SELECT on performance_schema |
backupSecretName | backup and restore jobs | SELECT, 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.
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:
spec.credentialsis required; a manifest that still carriesspec.secretNamegetsunknown field "spec.secretName"fromkubectl apply(strict field validation)spec.sites[].namemust be unique (spec.sitesis a list keyed by name)- every site has a
serverIDbetween 1 and 4294967295, unique in the group and immutable once set: it is the MySQLserver_id, so it does not change when sites are reordered - at least two sites with role
PrimaryCandidate splitBrainPolicy.sitePrioritiesentries must namePrimaryCandidatesitestaintNodeSelectorandloadBalancerIPare required unless the role isReadOnlysidecar.peerCheckIntervalbetween 1s and 1m,sidecar.leaseTimeoutbetween 3s and 10m, andleaseTimeout≥ 3 ×peerCheckInterval- every timer is bounded, and a zero is refused rather than replaced by the default:
pollInterval1s–5m,failoverCooldown30s–24h,connectionDrainTimeout1s–10m,cloneTimeout1m–24h,plannedFailover.maxLagWait1s–1h,plannedFailover.drainTimeout1s–10m encryptionAtRest.enabledrequiresspec.tlsserviceTemplate.externalTrafficPolicyrequires an effective type ofNodePortorLoadBalancerspec.siteshas between 2 and 16 entries
Not caught by anything:
- a
taintNodeSelectornaming labels no node carries - a
storageClassNamethat does not exist — the PVC simply staysPending spec.imageset to an unsupported MySQL version; there is no version admission check at all, and it surfaces as MySQL pod failures rather than an operator error- a floating tag such as
mysql:9, which can drift you onto an unsupported version between restarts maxLagSecondschosen as though it gated promotion- an operator Secret whose
usernameandpasswordname credentials MySQL does not have - backups configured on a PVC in the same storage class as the data
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.
What the operator creates for you
Per-site Deployment, PVC and ConfigMap; eight Services for three sites; a PodDisruptionBudget; the bloodraven-<group> DNSEndpoint; and the init-users ConfigMap that creates the MySQL users.
What spec.credentials requires
spec.credentials is required, and so is its operatorSecretName (keys username, password, MYSQL_ROOT_PASSWORD). The other four role Secrets are optional; the operator creates a user per role with a fixed grant set. The single-Secret spec.secretName DSN mode was removed in v1.7.0.
Grants the replication user receives
REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN, CLONE_ADMIN ON *.*. The last two are the ones people forget when adopting an existing datadir, and their absence surfaces as a reclone that will not start or a backup that cannot take a consistent dump.
Grants the app user receives
DML and DDL on *.* — without GRANT OPTION, SUPER or any other administrative privilege. It cannot grant privileges to anyone. The grant sets are fixed and not configurable, which is what makes them an answer you can give a security review.
Grants the monitor and readonly users receive
monitor: PROCESS, REPLICATION CLIENT plus SELECT on performance_schema — it cannot read your data. readonly: SELECT, SHOW VIEW, SHOW DATABASES, PROCESS.
Where the MySQL users come from, and when
An operator-rendered init-users ConfigMap mounted at /docker-entrypoint-initdb.d. The MySQL entrypoint runs it once, on an empty datadir, before the server accepts external connections — and never again.
What adopting an existing datadir costs you
The entrypoint skips initialisation, so the init script never runs: no clone plugin, no replication user, and no app/readonly/monitor/backup users. You create them by hand with exactly the documented grants.
Does rotating a password in the credential Secret change MySQL?
No. The ALTER USER line only runs on a fresh datadir. Changing the Secret changes what the operator presents, not what MySQL accepts — and the site goes Unreachable for no visible reason. It does roll the pods, because the spec hash includes credential Secret data.
loadBalancerIP and taintNodeSelector
Required by a CEL rule unless the site's role is ReadOnly: taintNodeSelector and loadBalancerIP are required unless role is ReadOnly. A reader is never promoted and never tainted, so it needs neither.
Why MySQL pods should set requests equal to limits
Guaranteed QoS is granted only when every container sets equal CPU and memory requests and limits, 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.
isFreshDeploy — the three conditions, all required
Every site is reachable (ReadOnly or Writable), no site has ever had replication configured (SHOW REPLICA STATUS returns nothing), and no site holds data (empty GTID_EXECUTED, cross-checked against user schemas). Only then does the operator seed and clone.
Why emptiness is load-bearing in the fresh-deploy check
A populated cluster can reach the no-history, no-metadata state by restart amnesia — the promoted primary's own RESET REPLICA ALL erased its channel metadata. Treating that as fresh would seed by priority order and clone the newer side from the stale one, destroying every post-failover write.
Quiz
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)
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)
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)
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)
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)
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)
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)
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)
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)
Sample 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)