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

  1. Authorize a deploy pipeline through MysqlDatabase.spec.deploymentClients and take the migration mutex and a failover-hold in the required order
  2. Say exactly which operations a live hold defers, and which paths never consult a lease
  3. Answer a 409 revoked, 404 or 403 token_mismatch renewal 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.

Try itOne deploy, from observation to a held primary
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} 201
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":"failover-hold","operationId":"a1c3f0e2-…","instance":"counter-app","ttlSeconds":30}' -w ' %{http_code}'
{"kind":"failover-hold","operationId":"a1c3f0e2-…","token":"…","expiresAt":"…","topologyGeneration":17} 201
Recorded output. Run reveals what is already on the page — nothing executes, and no cluster is contacted.

Two 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.

CompareWhere a live failover-hold has authority
Defers until the hold is goneNever consults a lease
Planned failoverPhase Deferred, admission.deferReason: DeploymentHold, admission.retryTime = earliest hold expiry. Revalidates when the hold lapses.—
Ordered update, restore-in-placeBoth 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.
The right-hand column is the whole reason the API is safe to expose to a pipeline: nothing it can hold makes the group less able to survive losing a site.

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.

1 / 12

Quiz

Question 1 of 5

A migration is running under a live failover-hold when iad's node dies. Three polls later the operator promotes pdx. What happened to the hold, and what does the pipeline see on its next PUT?

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)

Question 2 of 5

A pipeline POSTs failover-hold first, then migration. The hold is refused with 403 and the migration is granted 201. It POSTs the hold again and now gets 201. This is the intended ordering: the hold can only be granted to the current migration holder.

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)

Question 3 of 5

Your renewal loop gets 423 unstable with retryAfterSeconds on a PUT, while the last confirmed expiresAt is 9 seconds away. What may the client do?

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)

Question 4 of 5

The pipeline's PUT returns 409 revoked with topologyGeneration: 18. The migration's second ALTER TABLE of three had already run against iad. pdx is now writable and healthy. Argue what the pipeline does next.

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)

Question 5 of 5

During a deploy the operator pod is restarted. When the new leader comes up, is the pipeline's failover-hold still deferring the planned failover that was queued behind it?

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)

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.