Deployments that ask first: the lease API
The deploy pipeline is the other thing that touches your primary on a schedule. A migration mutex, a hold that defers planned disruption and nothing else, and a generation counter that fences any migration the topology moved underneath.
By the end of this topic you can
- Authorize a deploy pipeline through
MysqlDatabase.spec.deploymentClientsand take themigrationmutex and afailover-holdin the required order - Say exactly which operations a live hold defers, and which paths never consult a lease
- Answer a
409 revoked,404or403 token_mismatchrenewal correctly: stop the heartbeat, decide by the DDL boundary, never reconnect to the new primary
You can move playground’s primary on purpose now, and you know what the counter app owes you on the
other side of that move. There is one more thing in your organisation that touches the primary on a
schedule, and it is not on call: the deploy pipeline. Tuesday’s release runs ALTER TABLE against
playground. So does the release that was retried from a second runner because the first one looked
stuck. And the planned failover from the last topic is booked for the same window, because nobody
looks at two calendars.
Bloodraven’s answer is the /deploy/v1 API, and it is deliberately narrow. It gives a deployment two
things: a group-wide migration mutex, and a renewable hold on planned disruption. It does not
run your migrations, it does not hand out MySQL credentials, and it never delays an emergency failover.
Hold those three refusals in mind for the whole topic; every design choice below follows from them.
Who is allowed to ask
The API is off by default. Turning it on is a chart value, auxiliary.deployAPI.enabled, and it needs
auxiliary.escrowTLS.enabled with a certificate Secret, because the API shares the operator’s TLS
listener on :8443 and there is no plaintext fallback. The operator’s plain HTTP port, 8082, never
serves it. Only the leader answers; a follower returns 503 {"error":"not_leader"}.
A caller identifies itself with a projected ServiceAccount token whose audience is bloodraven-deploy,
and the operator checks it with a TokenReview. That gets you authenticated, not authorized. Authorization
lives on the MysqlDatabase: its spec.deploymentClients[] lists one namespace and serviceAccount
pair per namespace, and the database’s spec.failoverGroupRef.name decides which group that pair may
coordinate. The lease’s instance is the MysqlDatabase resource name, not the SQL schema name.
Look at what the pipeline does not need. No permission to patch the MysqlFailoverGroup. No read on
any Secret. No MySQL account from Bloodraven at all — the application keeps whatever credential path it
already has. The authority to edit deploymentClients is the authority to grant coordination, and that
is the only new authority in the system.
curl -s --cacert ca.crt -H "Authorization: Bearer $(cat /var/run/deploy/token)" https://bloodraven.bloodraven.svc:8443/deploy/v1/groups/playground | jq '{activeSite, topologyGeneration, stable, leases}'{
"activeSite": "iad",
"topologyGeneration": 17,
"stable": true,
"leases": []
}curl -s --cacert ca.crt -H "Authorization: Bearer $(cat /var/run/deploy/token)" -X POST https://bloodraven.bloodraven.svc:8443/deploy/v1/groups/playground/leases -d '{"kind":"migration","operationId":"a1c3f0e2-…","instance":"counter-app","ttlSeconds":30}' -w ' %{http_code}'{"kind":"migration","operationId":"a1c3f0e2-…","token":"…","expiresAt":"…","topologyGeneration":17} 201curl -s --cacert ca.crt -H "Authorization: Bearer $(cat /var/run/deploy/token)" -X POST https://bloodraven.bloodraven.svc:8443/deploy/v1/groups/playground/leases -d '{"kind":"failover-hold","operationId":"a1c3f0e2-…","instance":"counter-app","ttlSeconds":30}' -w ' %{http_code}'{"kind":"failover-hold","operationId":"a1c3f0e2-…","token":"…","expiresAt":"…","topologyGeneration":17} 201Two leases, in one order
GET /deploy/v1/groups/{group} is an observation: the active site, topologyGeneration, the health
block, every in-flight operation phase, and stable with an unstableReason. stable is false during
any operation phase — including a planned failover sitting in Deferred — while a planned failover’s
automation hold still stands (unstableReason: AutomaticFailoverHeld), and whenever the group is
Degraded, SplitBrain or NoPrimary, recovery is pending, or replication is not running. Lag alone does
not make it false; your lag threshold is your policy, so read health.replicationLagSeconds and decide.
Then two POSTs to /leases, and the order is not optional. The migration lease is the mutex: one
per group, full stop. A second pipeline asking while it is held gets 409 held with the holder’s
operationId, instance and expiresAt, which is everything it needs to wait politely. The
failover-hold lease is granted only to whoever currently holds the migration mutex, with the same
operationId and instance; anyone else gets 403. Migration first, then hold.
Both carry a ttlSeconds clamped to [5, 120], and the client renews each with a PUT every
ttl/3. A successful renewal returns the new expiresAt and the topologyGeneration — compare it with
the one you were granted under, every time. If the group is between confirmed states, a renewal can
answer 423 unstable with a retryAfterSeconds; that is not a renewal, and it never extends the
deadline you last had confirmed. Repeating the original POST for the same operationId is allowed and
returns 200, but it rotates the token and invalidates the old one, so serialize it with your renewals.
The leases themselves are coordination.k8s.io/Lease objects, separate from the operator’s own
leader-election lease. Restarting the operator releases nothing and resets nothing. Expiry is judged on
the operator’s clock, so keep its nodes synchronised.
What a hold holds, and what it never touches
An unexpired failover-hold does exactly one thing: it defers planned disruption. A planned failover
that arrives while the hold is live lands in the Deferred phase you met last topic, with admission.deferReason
DeploymentHold, an admission.retryTime equal to the earliest hold expiry, and a message naming the
blocking operationId and instance. Every renewal of the hold pushes that retryTime along. When the hold
is released or lapses, the switchover re-enters Admitting, revalidates and proceeds on its own. Ordered updates and
restore-in-place defer behind the same hold. A migration lease on its own defers nothing.
| Defers until the hold is gone | Never consults a lease | |
|---|---|---|
| Planned failover | Phase Deferred, admission.deferReason: DeploymentHold, admission.retryTime = earliest hold expiry. Revalidates when the hold lapses. | — |
| Ordered update, restore-in-place | Both wait behind the hold in the same way. | — |
| Emergency failover | — | Promotes on the same detection budget you worked out in Unit 2. The hold is not read. |
| Returning-primary fencing, primary reassertion | — | Safety paths. A deployment cannot buy a delay on either. |
That right-hand column is the design, not a gap. If a hold could pause emergency promotion, a stuck pipeline would be a way to keep a dead primary nominally in charge. So the emergency path does not read leases at all, and the API has to answer a different question instead: what happens to the migration that was running when the primary moved anyway?
The generation is the fence
status.topologyGeneration is a durable counter that increments on every authoritative change of active
site — planned promotion, emergency failover, the failover inside an ordered update, restore-in-place
promotion, bootstrap completion. It is not the CR’s metadata.generation and it is not a restart
counter. Every lease is stamped with the generation it was granted under, and a generation change
revokes every lease in the group with reason TopologyChanged, raising a DeploymentLeaseRevoked
event on the MysqlFailoverGroup.
The pipeline finds out on its next heartbeat. PUT comes back 409 revoked with the new generation; a
lease that simply expired comes back 404; a wrong or stale token comes back 403 token_mismatch. All
three mean the same thing: you no longer hold authority. Stop the heartbeat. Do not reconnect to
the new primary and keep going. Whether the DDL had started is the only question left. Before it, run
the application’s own fenced abort. At or after it, stop writing and require a human to verify the
schema — a healthy new primary does not prove a half-applied migration is safe. Release the hold on every
failure path; release the migration mutex only after the deploy’s completion or failure state is
safely recorded. And the sentence to keep: a TTL is not an exactly-once DDL guarantee.
The operator keeps a revocation tombstone per operation until the group is deleted, so a client that
survived a revocation and retries its POST still gets 409 revoked even after another operation has
taken the mutex. Tombstones hold token hashes, never tokens; budget for them when you size the API
server.
Revoking by hand
When you need every lease gone — a pipeline that will not die, a hold you must clear before a
switchover — annotate the group with bloodraven.shipstream.io/revoke-deployment-leases. Renewals
then report OperatorRevoked, and the same fenced-abort rules apply to the client. What you never do is
delete the leader-election Lease or edit topologyGeneration to clear a hold; both are ways of lying to
the fence. bloodraven_deploy_leases_active and bloodraven_planned_failovers_deferred_total tell you
whether a hold is live and how often it has cost a switchover, and every request logs msg="deploy api"
with handler, group, instance, operationId and status.
Where this leaves you
The application half of failover is now complete in both directions: the operator moving the primary
under your app, and your pipeline moving the schema under the operator. You can authorize a runner
without giving it the cluster, take the two leases in order, read Deferred/DeploymentHold for what it
is, and answer a 409 revoked with a stop rather than a reconnect.
Everything in this unit was checked by reading status and logs by hand. The next unit asks what the operator exports on its own, where it goes blind, and what you should be alerting on before an incident makes you look.
Flashcards
Which chart value turns on the deployment API, and what must already be on for it to work?
auxiliary.deployAPI.enabled. It requires auxiliary.escrowTLS.enabled with a certificate Secret, because /deploy/v1 is served only on the shared TLS listener on :8443 — never on plain HTTP 8082.
What audience must a deployment client's projected ServiceAccount token carry?
bloodraven-deploy. The operator validates the token with a TokenReview against that audience; the Kubernetes API's default audience is not accepted.
Where does authorization for the deployment API live, and what is the lease's instance?
On the MysqlDatabase: spec.deploymentClients[] lists one namespace + serviceAccount pair per namespace, and spec.failoverGroupRef.name names the group. instance is the MysqlDatabase resource name, not the SQL schema.
Name the three things the deployment API refuses to do.
It does not run migrations, it does not grant MySQL credentials, and it never delays emergency failover.
A second pipeline POSTs a migration lease while one is held. What comes back?
409 held, with the holder's operationId, instance and expiresAt — one migration lease per group, no exceptions.
Who can be granted a failover-hold?
Only the current migration holder, using the same operationId and instance. Anyone else gets 403. Take migration first, then failover-hold.
What is the TTL clamp, and how often should a client renew?
ttlSeconds is clamped to [5, 120]. Renew each lease with PUT every ttl/3, and compare the returned topologyGeneration with the grant generation every time.
What makes stable=false in a GET — and what does not?
Any operation phase (including a planned failover in Deferred), Degraded, SplitBrain or NoPrimary, recovery pending, or replication not running. Replication lag alone does not; the lag threshold is the caller's policy.
A planned failover is requested while a failover-hold is live. What does status show?
plannedFailover.phase: Deferred, admission.deferReason: DeploymentHold, admission.retryTime equal to the earliest hold expiry, and a message naming the blocking operationId and instance. The retryTime moves with every hold renewal.
Which paths never consult a deployment lease?
Emergency failover, returning-old-primary fencing, and primary reassertion. A hold defers only planned failover, ordered update and restore-in-place; a migration lease alone defers nothing.
What increments status.topologyGeneration, and what does a bump do to leases?
Every authoritative active-site change — planned promotion, emergency failover, ordered-update failover, restore-in-place promotion, bootstrap completion. A bump revokes every lease in the group with TopologyChanged and raises a DeploymentLeaseRevoked event.
How do you revoke every deployment lease by hand, and what must you never do instead?
Annotate the group with bloodraven.shipstream.io/revoke-deployment-leases; renewals then report OperatorRevoked. Never delete the leader-election Lease or edit topologyGeneration to clear a hold.
Quiz
Show answer
Answer: The hold was not consulted; the promotion bumped topologyGeneration and revoked both leases, so the PUT returns 409 revoked with reason TopologyChanged.
Emergency failover never reads deployment leases — a hold that could pause it would let a stuck pipeline keep a dead primary in charge. The promotion increments status.topologyGeneration, which revokes every lease in the group with TopologyChanged, and the client learns it on the next heartbeat as 409 revoked. (objectives 14, 15)
Emergency failover never reads deployment leases — a hold that could pause it would let a stuck pipeline keep a dead primary in charge. The promotion increments status.topologyGeneration, which revokes every lease in the group with TopologyChanged, and the client learns it on the next heartbeat as 409 revoked. (objectives 14, 15)
Show answer
Answer: True
A failover-hold is granted only to the operation that currently holds the migration mutex, with the same operationId and instance; any other caller is refused with 403. The documented order is migration first, then hold. (objective 13)
A failover-hold is granted only to the operation that currently holds the migration mutex, with the same operationId and instance; any other caller is refused with 403. The documented order is migration first, then hold. (objective 13)
Show answer
Answer: Keep observing within the 9 seconds it already had confirmed, and stop if no 200 arrives before that deadline — the 423 never extends the deadline.
A 423 unstable on renewal is a transient refusal, not a renewal. It never permits work beyond the last confirmed deadline, so the client may only continue observing inside that window. Re-POSTing to bypass an uncertain state is exactly what the docs forbid. (objectives 13, 15)
A 423 unstable on renewal is a transient refusal, not a renewal. It never permits work beyond the last confirmed deadline, so the client may only continue observing inside that window. Re-POSTing to bypass an uncertain state is exactly what the docs forbid. (objectives 13, 15)
Show answer
Answer:
Stop the heartbeat and do not reconnect to pdx to finish the remaining DDL — pdx being healthy says nothing about whether the partially applied change replicated intact or is safe to continue. Because DDL had started, stop all database writes from the deploy and require a human to verify the schema before any retry. Release the failover-hold now; release the migration mutex only after the deploy's failure state is recorded. A TTL is not an exactly-once DDL guarantee, and the tombstone means this operationId will be refused with 409 if it tries again.
A full-credit answer shows: A strong answer covers: (1) 409 revoked means lost authority — stop the heartbeat, never continue on the new primary; (2) the decision turns on the DDL boundary, and here DDL had started, so stop writes and require manual schema verification rather than the fenced abort; (3) a healthy new primary does not prove a half-applied migration is safe; (4) release the hold on the failure path and the mutex after the terminal state is recorded; (5) optionally, the revocation tombstone refuses a retry of the same operation.
The fence is the generation, and losing it is terminal for that operation. Before DDL the client runs its fenced abort; at or after DDL it stops writing and hands the schema to a human. Reconnecting to the new primary to 'finish up' is the failure the API exists to prevent. (objective 15)
Sample answer
Stop the heartbeat and do not reconnect to pdx to finish the remaining DDL — pdx being healthy says nothing about whether the partially applied change replicated intact or is safe to continue. Because DDL had started, stop all database writes from the deploy and require a human to verify the schema before any retry. Release the failover-hold now; release the migration mutex only after the deploy's failure state is recorded. A TTL is not an exactly-once DDL guarantee, and the tombstone means this operationId will be refused with 409 if it tries again.
A full-credit answer shows
A strong answer covers: (1) 409 revoked means lost authority — stop the heartbeat, never continue on the new primary; (2) the decision turns on the DDL boundary, and here DDL had started, so stop writes and require manual schema verification rather than the fenced abort; (3) a healthy new primary does not prove a half-applied migration is safe; (4) release the hold on the failure path and the mutex after the terminal state is recorded; (5) optionally, the revocation tombstone refuses a retry of the same operation.
The fence is the generation, and losing it is terminal for that operation. Before DDL the client runs its fenced abort; at or after DDL it stops writing and hands the schema to a human. Reconnecting to the new primary to 'finish up' is the failure the API exists to prevent. (objective 15)
Show answer
Answer: Yes, until its unrenewed TTL expires — leases are coordination.k8s.io/Lease objects in the API server, and an operator restart neither releases nor resets them.
Deployment leases are durable Lease objects, separate from leader election; restarting the operator does not release or reset them, and topologyGeneration is a persisted count of active-site changes, not a restart counter. The hold keeps deferring until it expires on the operator's clock or is released. (objectives 13, 14)
Deployment leases are durable Lease objects, separate from leader election; restarting the operator does not release or reset them, and topologyGeneration is a persisted count of active-site changes, not a restart counter. The hold keeps deferring until it expires on the operator's clock or is released. (objectives 13, 14)