Skip to content

docs(adr): add ADR for Active/Standby HA controller (#293) - #405

Open
sumanthd032 wants to merge 6 commits into
kubeslice:masterfrom
sumanthd032:docs/ha-active-standby
Open

docs(adr): add ADR for Active/Standby HA controller (#293)#405
sumanthd032 wants to merge 6 commits into
kubeslice:masterfrom
sumanthd032:docs/ha-active-standby

Conversation

@sumanthd032

Copy link
Copy Markdown

Add the design record for multi-cluster Active/Standby HA of the kubeslice-controller: one Active hub holds a Lease and does all writes while a Standby mirrors its state and watches the Lease, promoting itself on Active failure.

Fixes #293

How Has This Been Tested?

N/A - documentation-only change (ADR). No code paths are modified, so there is nothing to run. Reviewed by rendering the Markdown, verifying the two sequence diagrams, and checking each decision against the required-answers table in issue #293.

Checklist:

  • The title of the PR states what changed and the related issues number (used for the release note).
  • Does this PR requires documentation updates?
  • I've updated documentation as required by this PR.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have tested it for all user roles.
  • I have added all the required unit test cases.

Does this PR introduce a breaking change for other components like worker-operator?

No. This is a design document only and changes no runtime behavior.

Add the design record for multi-cluster Active/Standby HA of the
kubeslice-controller: one Active hub holds a Lease and does all writes
while a Standby mirrors its state and watches the Lease, promoting itself
on Active failure.

Signed-off-by: Sumanth D <sumanthd032@gmail.com>
@sumanthd032
sumanthd032 marked this pull request as ready for review July 3, 2026 12:04
Copilot AI review requested due to automatic review settings July 3, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an Architecture Decision Record (ADR) documenting the proposed multi-cluster Active/Standby HA design for the KubeSlice controller, capturing key decisions and expected operational behavior as a prerequisite for the downstream implementation work.

Changes:

  • Introduces an ADR describing the Active/Standby hub architecture, goals/non-goals, and core components.
  • Documents explicit design decisions for lease placement, credential bootstrap, fencing, promotion triggers, state mirroring, worker reconnection, and split-brain handling.
  • Adds Mermaid-based architecture + sequence diagrams and a table enumerating mirrored resources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/adr/ha-active-standby-adr.md Outdated
Comment thread docs/adr/ha-active-standby-adr.md
@sumanthd032
sumanthd032 marked this pull request as draft July 3, 2026 17:15
@sumanthd032
sumanthd032 marked this pull request as ready for review July 3, 2026 17:18
@gourishkb
gourishkb requested a review from rajendra-avesha July 6, 2026 13:28
@sumanthd032

Copy link
Copy Markdown
Author

@pnavali @gourishkb - review comments addressed and the ADR is updated. Please do a final review before merging. Thanks!

@pnavali
pnavali requested review from gourishkb and removed request for that-backend-guy July 8, 2026 11:07
Signed-off-by: Sumanth D <sumanthd032@gmail.com>
@sumanthd032
sumanthd032 force-pushed the docs/ha-active-standby branch from d2b4318 to 43dcc20 Compare July 8, 2026 17:18

@gourishkb gourishkb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good structure overall — the scope note correctly dismissing --leader-elect as orthogonal is exactly right, and the decision to mirror Secrets/RBAC/Namespaces (not just CRDs) is the kind of detail that prevents a promoted hub from being stuck waiting for credential propagation. A few things need resolution before this gates implementation.

Must clarify before #294/#295/#297 start

  1. status.activeController ambiguity. Decision 7 and the diagram describe this field as living on the Active hub's Cluster CR (ac_cluster in the flowchart), yet the worker is supposed to watch it to learn the new Active. But the worker can't reach the hub's API when the hub is down — that's the exact failure scenario. Please clarify: does the new Active write this signal to a CR on each worker cluster after promotion (so the worker watches a local CR), or does it live on the hub (and if so, how does the worker read it when the hub is unreachable)? This needs to be precise because worker-operator #467 depends on it.

  2. Namespace: kubeslice-controller vs kubeslice-system. Decision 2 places the active-hub-kubeconfig Secret in namespace kubeslice-controller, but the controller deploys into kubeslice-system. Please verify and use the correct namespace consistently throughout the ADR.

  3. Open Issue #2 (finalizers / stuck Terminating objects) must be resolved before any code merges. This isn't something that can be addressed post-MVP. If StateMirror writes objects with finalizers (e.g., Cluster, SliceConfig) and those objects are then deleted on the Active, the mirror propagates the deletion to the Standby. But on the Standby, the finalizer controllers are gated (IsLeader()=false), so the object sits in Terminating forever. This leaks state and corrupts the Standby's view of the world. The ADR should propose a resolution — stripping finalizers from mirrored copies, or skipping the deletion mirror and relying on a periodic prune-and-resync — before implementation begins.

Should fix

  1. Background goroutine gating is underspecified. Decision 4 mentions gating reconcilers and says "background writers (e.g. the VpnKeyRotation timer) start only when IsLeader()". But main.go also starts other background processes: the metrics server, health-check server, leader-election renewer. Which processes run on both Active and Standby, and which are gated? The ADR should enumerate all goroutines started by main.go and explicitly mark each as "always runs", "Active only", or "Standby only". This directly determines how --ha-mode changes main.go's startup path.

  2. ENABLE_WEBHOOKS=false on Standby — security note. Disabling webhooks on the Standby so StateMirror can write objects without triggering validation is pragmatic, but it means that if an operator mistakenly applies a SliceConfig directly to the Standby while it is in Standby role, that invalid config will be accepted. Please add a note in the ADR that the Standby's API server should ideally be unreachable from external clients (network policy or no exposed route), and that ENABLE_WEBHOOKS=false is a deployment-level guard, not a security boundary.

Minor

  1. The recovery path ("a recovered failed hub re-joins as Standby by manual redeploy") is mentioned in one sentence. This deserves a paragraph in the Operational Model or Non-Goals section — specifically: what state does the recovered hub have, how does an operator wipe and redeploy it as Standby, and what happens if the operator skips the wipe step and redeploys without clearing the old Lease.

Comment thread docs/adr/ha-active-standby-adr.md
Comment thread docs/adr/ha-active-standby-adr.md
Comment thread docs/adr/ha-active-standby-adr.md
Comment thread docs/adr/ha-active-standby-adr.md
- Clarify the Lease/Secret namespace is read from the downward API
  (KUBESLICE_CONTROLLER_MANAGER_NAMESPACE), not a hardcoded literal;
  confirm kubeslice-controller (not kubeslice-system) is correct, and
  add the missing --ha-lease-namespace flag.
- Resolve the finalizer/stuck-Terminating open issue: StateMirror
  strips finalizers on write, with periodic prune-on-resync as a
  backstop for missed deletes.
- Enumerate every process main.go starts and mark each as
  always-runs/Active-only/Standby-only; add a security note that
  ENABLE_WEBHOOKS=false is a deployment convenience, not a security
  boundary.
- Fix --ha-mode's documented default and align the Decision 3/4 code
  snippets with the merged kubeslice#294 implementation (LeaderElector field
  name, nil-check, and the actual tested "skipping reconcile" log
  line).
- Add an open issue on the worker's failover-time detection of
  status.activeController across both hubs, for #467 to settle.

Signed-off-by: Sumanth D <sumanthd032@gmail.com>
@sumanthd032

Copy link
Copy Markdown
Author

@gourishkb All items addressed in d521a70 - namespace, finalizers, goroutine gating, and webhook security note fixed;

status.activeController ambiguity. Decision 7 and the diagram describe this field as living on the Active hub's Cluster CR (ac_cluster in the flowchart), yet the worker is supposed to watch it to learn the new Active. But the worker can't reach the hub's API when the hub is down — that's the exact failure scenario. Please clarify: does the new Active write this signal to a CR on each worker cluster after promotion (so the worker watches a local CR), or does it live on the hub (and if so, how does the worker read it when the hub is unreachable)? This needs to be precise because worker-operator #467 depends on it.

This one is added as Open Issue 4, still needs your input.

@gourishkb gourishkb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All blocking items from the previous round are addressed in d521a70:

  • Finalizers / stuck-Terminating → resolved in Decision 6: StateMirror strips all finalizers on write, with periodic prune-on-resync as a backstop for missed deletes. No longer an open issue.
  • Namespace--ha-lease-namespace now defaults to KUBESLICE_CONTROLLER_MANAGER_NAMESPACE (downward API), with kubeslice-controller only as a last-resort fallback. Correct.
  • Goroutine gating → the flags table now enumerates what runs on Active-only vs always, and ENABLE_WEBHOOKS=false on Standby is documented with the security note.
  • Webhook security note → added.

Open Issue 4 (status.activeController dual-watch mechanism) is the right place for this — the analysis is thorough and the direction (two permanent watches, each hub self-declares, worker trusts whichever is reachable and self-declares Active) is sound. The two remaining sub-items (tie-break rule and credential prerequisite) must be resolved with the worker-operator maintainers before worker-operator #467 is opened — please do not start #467 until that is settled.

Approving. This ADR is ready to merge as a design gate for the downstream implementation issues.

…slice#297)

Designing the promotion path against the implemented leader election and
state mirror surfaced four places where this ADR was wrong or silent, and
two where its wording invited an incorrect implementation. Amending it
here so the design document and the code being built from it agree.

Decision 5 — what triggers promotion. Rewritten.

  * Detection only covered "Lease readable but renewTime frozen". It said
    nothing about a Lease that cannot be read at all, so the loss of an
    entire hub - API server, node, or cluster - was undetectable. Both
    failures are the same event from the Standby's seat: the newest proof
    of life it holds stops advancing. Restated as one rule over the newest
    renewTime ever successfully read, which covers both with one threshold.

  * Added the two conditions that were missing entirely. A Standby must
    have read the Active's Lease at least once before it may promote, so a
    bad kubeconfig or a missing RBAC grant cannot masquerade as a dead
    Active; and it must confirm its own API server answers, so its own
    network failing is not mistaken for the Active's. Both are recorded
    with their costs, including that the first means a Standby restarting
    mid-outage will not promote.

  * The final dial was framed as the safety mechanism. It is not - in a
    partition it travels the same broken path and fails identically, so
    the Standby promotes anyway. Described as what it is: a guard against
    the Active having renewed between polls.

  * Step order corrected. Stopping the mirror came after leadership was
    granted. In the most common trigger the Active's API server is still
    healthy, so the mirror is still running: it would overwrite what the
    promoted reconcilers write, and the resync's reverse diff would
    re-create what they delete. The mirror must stop, and be confirmed
    stopped, before the write fence opens.

  * Added the re-enqueue step. The Decision 4 fence drops requests rather
    than requeuing them, so flipping it causes no reconcile at all and
    pre-existing mirrored state - which carries no finalizers until a
    reconciler re-adds them - would sit untouched until the informer
    resync period. Noted that a test which only creates new objects passes
    without this, since a new object generates its own event.

  * Specified the Event: the controller's own namespace, with the acquired
    Lease as the involved object. The namespace it belongs in was stated
    correctly in Decision 1 but never restated here, and kubeslice-system
    exists only on workers.

Decision 6 — "each hub mints its own token" was written as a property the
system has. It is a requirement on the registration path, which today
creates the token Secret only when the ServiceAccount is absent. A
mirrored ServiceAccount arrives without its Secret, so that branch never
runs and a promoted hub fails every cluster reconcile while reporting
success. Stated as the requirement it is.

Decision 7 — the activeController field was described as something a
promoted hub sets, which leaves a worker unable to identify the Active
before any failover has happened. Open Issue 4 already requires each hub
to self-declare while it holds leadership; made that a continuous local
publisher rather than a promotion step, stated that the write never
crosses into a worker cluster, and required a hub still carrying the
placeholder endpoint to refuse to publish rather than advertise an
unreachable failover target.

Decision 8 — recorded why a recovered Active cannot be demoted
automatically. The two Leases are separate objects on separate API
servers, so there is no shared record of who won; a recovered hub reads
its own Lease, sees itself as holder, and resumes.

Flags — the two promotion durations were described in near-synonymous
terms and are not the same setting: one decides when the Active is
considered dead, the other how long promotion waits on its own
publication step. Rewritten to distinguish them, and noted that issue
kubeslice#297's --ha-promotion-grace is an alias of the shipped
--ha-padding-seconds and is deliberately not implemented. Added the dial
timeout and self CA bundle path.

The worker's pre-failover credential for the Standby remains open and is
deliberately not settled here; it spans this repo and worker-operator and
needs maintainer agreement on where it lands.

Signed-off-by: Sumanth D <sumanthd032@gmail.com>
Implementing the promotion sequence surfaced a hazard this decision did not
name, in the step it reasoned about most carefully.

Stopping the mirror before the write fence opens is the step where an
unbounded wait looks cautious: proceeding without a stopped mirror is
exactly the dual-writer state the ordering exists to prevent. But the
Standby evaluates staleness in a single loop that runs the promotion
sequence inline, so a step that blocks forever blocks that loop — no
further polls, no further staleness evaluation, no failover ever, and
nothing logged after the sequence began. "Never promote into a dual writer"
silently becomes "never promote at all", which is worse and invisible.

So every step that waits on another component is bounded. Expiry before the
fence opens aborts the attempt and the next poll retries; expiry after it is
logged against a hub that is already leading.

Both flag descriptions are corrected to match what they actually bound. The
dial timeout covers every networked Lease read, including each periodic
poll — an unbounded poll stalls detection for as long as the read hangs,
which live testing measured at twelve seconds against an API server that
was shutting down and which has no upper limit against one that simply
stops answering. The grace period covers four sequencing steps, not only
the publication one.

Signed-off-by: Sumanth D <sumanthd032@gmail.com>
Decision 7 requires a worker to already hold a credential for both hubs.
Open Issue 4 said the mechanism for that was unsolved. The two have
contradicted each other since the design was written, and the contradiction
was left in deliberately while the mechanism was undecided.

Decision 6 now carries it: the mirror copies the worker token Secret's
shell, without the token value or the account UID annotation, and the
Standby's own token controller populates it. A Standby holds a credential
valid on itself continuously, before any failover, with no unfencing.
Records the two properties that are easy to get wrong — the shell must be
create-only or resyncs rotate the token underneath the worker, and the
Standby now caches every Secret cluster-wide, which widens caching but not
access.

Open Issue 4 keeps only what is genuinely still open: delivering that token
to the worker at registration, which lives in the charts and the documented
registration procedure rather than in either repo, and consuming it, which
is worker-operator #467.

Also corrects the registration-path paragraph, which assumed a mirrored
ServiceAccount arrives without its token Secret. It now normally arrives
with the shell; the independent existence check is still required, for the
cases the mirror cannot cover.

Part of kubeslice#293

Signed-off-by: Sumanth D <sumanthd032@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design: Active/Standby HA for KubeSlice Controller (ADR)

3 participants