Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cef0309
feat(ha): add ClusterLeaderElector and write-fencing primitives
sumanthd032 Jul 6, 2026
974e9fe
feat(ha): gate all reconcilers on cross-cluster leadership
sumanthd032 Jul 6, 2026
8ebbc71
chore(ha): drop redundant coordination/leases RBAC marker
sumanthd032 Jul 7, 2026
0da6d82
fix(ha): round lease duration up to >=1s and return nil on graceful s…
sumanthd032 Jul 7, 2026
01e90d8
fix(ha): default HA Lease namespace to the controller's own namespace
sumanthd032 Jul 8, 2026
e83c0f5
fix(ha): prefer downward-API namespace over hard-coded Lease default
sumanthd032 Jul 12, 2026
240ee1d
test(ha): cover graceful-shutdown and error paths left untested
sumanthd032 Jul 16, 2026
e72fd31
feat(ha): add generic mirror engine (create/update/delete, conflict g…
sumanthd032 Jul 17, 2026
d69bf7d
feat(ha): add ha_sync_lag_seconds and ha_sync_errors_total metrics
sumanthd032 Jul 17, 2026
7680d34
feat(ha): add rate-limited workqueue + remote informer wiring
sumanthd032 Jul 17, 2026
1bea917
feat(ha): wire RemoteSyncer into main.go standby startup
sumanthd032 Jul 17, 2026
280d498
docs(ha): add sample least-privilege ClusterRole for the Standby's re…
sumanthd032 Jul 17, 2026
1f7c5c8
fix(ha): retry remote informer setup and scope Namespace mirroring
sumanthd032 Jul 17, 2026
65b7b92
fix(ha): strip deletionTimestamp and status from Terminating sources
sumanthd032 Jul 18, 2026
59d3f95
fix: close deployment/RBAC gaps around HA standby mode
sumanthd032 Jul 18, 2026
5d8b2a8
feat(ha): add periodic prune-only resync (drift backstop)
sumanthd032 Jul 21, 2026
6d4f2b4
feat(ha): register HAMirrorSyncFailed event and emit it on mirror fai…
sumanthd032 Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ COPY util/ util/
COPY events/ events/
COPY metrics/ metrics/
COPY cleanup/ cleanup/
COPY pkg/ pkg/

# Build
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o manager main.go
Expand Down
6 changes: 6 additions & 0 deletions config/events/controller.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,9 @@ events:
type: Warning
reportingController: controller
message: Warning - Certificate Creation job Failed
- name: HAMirrorSyncFailed
reason: HAMirrorSyncFailed
action: HAMirrorSync
type: Warning
reportingController: controller
message: Failed to mirror a resource from the Active hub onto the Standby; the syncer will retry.
3 changes: 2 additions & 1 deletion config/events/events_config_map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,5 @@ data:
- CertificateJobCreationFailed
- CertificatesRenewNow
- IllegalVPNKeyRotationConfigDelete
- CertificateJobFailed
- CertificateJobFailed
- HAMirrorSyncFailed
48 changes: 48 additions & 0 deletions config/ha/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# HA cross-cluster RBAC (issue #295)

`active-cluster-clusterrole.yaml` is a least-privilege grant for the
identity behind a Standby's `--ha-active-kubeconfig` flag: read-only
(`get`/`list`/`watch`) access to `Namespace` plus every resource type in
`pkg/ha.CRDMirrorSet` (`RemoteSyncer` never writes to the Active cluster —
only reads), plus the Active's own `coordination.k8s.io/v1` `Lease` —
the same kubeconfig is also used by #294's `WatchRemoteLease` to read the
Active's Lease directly, not just by `RemoteSyncer` to mirror CRDs.

## This is not applied by this repo's own deploy flow

Nothing here is referenced by `config/rbac/kustomization.yaml` or
`config/default/kustomization.yaml`. Those govern the RBAC a controller
grants *itself* on the cluster it's running in. This manifest is different:
it must be applied **on the Active hub cluster**, granting access to
whatever identity the *Standby's* kubeconfig authenticates as — a cluster
this repo's own kustomize overlays have no way to reach, since it's a
separate cluster entirely.

Apply it manually (or via whatever provisioning tooling manages the Active
hub) against the Active cluster:

```
kubectl --context <active-hub-context> apply -f active-cluster-clusterrole.yaml
```

Fill in the `ClusterRoleBinding`'s `subjects` first — the correct subject
depends on how the Standby authenticates to the Active (a `ServiceAccount`
if dialing in-cluster, a client-cert `User` if using a flattened
kubeconfig Secret, as the current dev demo does).

## A known, deliberate gap this manifest does not close

Nothing in this repo automates applying this to a real Active cluster —
that's cross-cluster provisioning, out of scope for a single controller
repo. Logged as a follow-up, not built.

## Credential mirroring (a later PR)

If/when `pkg/ha.CredentialMirrorSet` (Secrets, ServiceAccounts, Roles,
RoleBindings — for #297's post-promotion use) is wired in, this
`ClusterRole` will need `secrets`/`serviceaccounts`/`roles`/`rolebindings`
appended. Worth knowing ahead of time: RBAC cannot scope `Secret` access by
`.type`, so that addition grants read access to **every** Secret in the
project namespaces on the Active cluster, not just the credential ones
`RemoteSyncer` actually mirrors — a real credential-exposure tradeoff to
weigh when that lands, not just an implementation detail.
78 changes: 78 additions & 0 deletions config/ha/active-cluster-clusterrole.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
# Least-privilege grant for the identity behind the Standby's
# --ha-active-kubeconfig, applied ON THE ACTIVE CLUSTER — not part of this
# repo's own config/rbac (that governs the local cluster's own ClusterRole)
# and not referenced by config/rbac/kustomization.yaml or
# config/default/kustomization.yaml, so it is never auto-applied to the
# Standby's own cluster, where it would be meaningless.
#
# See config/ha/README.md for how and where to apply this.
#
# Scope: read-only (get/list/watch) on Namespace plus every type in
# pkg/ha.CRDMirrorSet, plus the Active's own HA Lease — the same
# --ha-active-kubeconfig identity is also used by #294's WatchRemoteLease to
# read the Active's coordination.k8s.io/v1 Lease (checkRemoteLeaseOnce),
# not just by RemoteSyncer. Confirmed live: without this, the Standby can
# mirror CRDs fine but permanently fails to read the Active's Lease,
# so it can never observe staleness in the first place.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kubeslice-ha-standby-reader
rules:
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- list
- watch
- apiGroups:
- controller.kubeslice.io
resources:
- projects
- clusters
- sliceconfigs
- serviceexportconfigs
- sliceqosconfigs
- vpnkeyrotations
verbs:
- get
- list
- watch
- apiGroups:
- worker.kubeslice.io
resources:
- workersliceconfigs
- workerslicegateways
- workerserviceimports
verbs:
- get
- list
- watch
---
# Template only — the subject is deployment-specific (a ServiceAccount if the
# Standby dials the Active in-cluster, a cert CN if it uses client-cert auth
# via a flattened kubeconfig) and can't be hardcoded here. Fill in `subjects`
# before applying.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kubeslice-ha-standby-reader-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kubeslice-ha-standby-reader
subjects:
- kind: User # or ServiceAccount — see README.md
name: CHANGEME
apiGroup: rbac.authorization.k8s.io
8 changes: 8 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resources:
- namespaces/status
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
Expand Down
11 changes: 11 additions & 0 deletions controllers/controller/cluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package controller

import (
"context"

"github.com/kubeslice/kubeslice-monitoring/pkg/events"
"go.uber.org/zap"

controllerv1alpha1 "github.com/kubeslice/kubeslice-controller/apis/controller/v1alpha1"
"github.com/kubeslice/kubeslice-controller/pkg/ha"
"github.com/kubeslice/kubeslice-controller/service"
"github.com/kubeslice/kubeslice-controller/util"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -36,6 +38,9 @@ type ClusterReconciler struct {
ClusterService service.IClusterService
Log *zap.SugaredLogger
EventRecorder *events.EventRecorder
// LeaderElector gates mutating reconciles on cross-cluster leadership. It is
// nil-safe: a nil elector (HA not wired) behaves as standalone. See ADR #293.
LeaderElector *ha.ClusterLeaderElector
}

// SetupWithManager sets up the controller with the Manager.
Expand All @@ -47,6 +52,12 @@ func (c *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {

// Reconcile is a function to reconcile the cluster , ClusterReconciler implements it
func (c *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// HA write fence: only the Active hub (or a standalone controller) writes.
// A Standby evaluates this on every call and no-ops.
if c.LeaderElector != nil && !c.LeaderElector.IsLeader() {
c.Log.Info("standby mode, skipping reconcile")
return ctrl.Result{}, nil
}
kubeSliceCtx := util.PrepareKubeSliceControllersRequestContext(ctx, c.Client, c.Scheme, "ClusterController", c.EventRecorder)
return c.ClusterService.ReconcileCluster(kubeSliceCtx, req)
}
62 changes: 62 additions & 0 deletions controllers/controller/leader_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2022 Avesha, Inc. All rights reserved. # # SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package controller

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
ctrl "sigs.k8s.io/controller-runtime"

"github.com/kubeslice/kubeslice-controller/pkg/ha"
)

// TestReconcile_StandbySkipsAndLogs verifies the HA write fence: a Standby
// controller returns immediately from Reconcile without touching its service
// (left nil here — a leaking gate would panic), and logs the skip message on
// every call, proving IsLeader() is evaluated per invocation.
func TestReconcile_StandbySkipsAndLogs(t *testing.T) {
core, logs := observer.New(zapcore.InfoLevel)
logger := zap.New(core).Sugar()

standby := ha.NewClusterLeaderElector(nil, nil, ha.Options{
Mode: ha.ModeStandby,
Log: zap.NewNop().Sugar(),
})
require.False(t, standby.IsLeader(), "standby must not be leader")

r := &SliceConfigReconciler{
Log: logger,
LeaderElector: standby,
// SliceConfigService is intentionally nil: the gate must return before it.
}

const calls = 3
for i := 0; i < calls; i++ {
res, err := r.Reconcile(context.Background(), ctrl.Request{})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, res)
}

assert.Equal(t, calls, logs.FilterMessage("standby mode, skipping reconcile").Len(),
"expected one skip log per Reconcile call")
}
11 changes: 11 additions & 0 deletions controllers/controller/project_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package controller

import (
"context"

"github.com/kubeslice/kubeslice-monitoring/pkg/events"
"go.uber.org/zap"

controllerv1alpha1 "github.com/kubeslice/kubeslice-controller/apis/controller/v1alpha1"
"github.com/kubeslice/kubeslice-controller/pkg/ha"
"github.com/kubeslice/kubeslice-controller/service"
"github.com/kubeslice/kubeslice-controller/util"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -36,6 +38,9 @@ type ProjectReconciler struct {
ProjectService service.IProjectService
Log *zap.SugaredLogger
EventRecorder *events.EventRecorder
// LeaderElector gates mutating reconciles on cross-cluster leadership. It is
// nil-safe: a nil elector (HA not wired) behaves as standalone. See ADR #293.
LeaderElector *ha.ClusterLeaderElector
}

// SetupWithManager sets up the controller with the Manager.
Expand All @@ -47,6 +52,12 @@ func (t *ProjectReconciler) SetupWithManager(mgr ctrl.Manager) error {

// Reconcile is a function to reconcile the project, ProjectReconciler implements it
func (t *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// HA write fence: only the Active hub (or a standalone controller) writes.
// A Standby evaluates this on every call and no-ops.
if t.LeaderElector != nil && !t.LeaderElector.IsLeader() {
t.Log.Info("standby mode, skipping reconcile")
return ctrl.Result{}, nil
}
kubeSliceCtx := util.PrepareKubeSliceControllersRequestContext(ctx, t.Client, t.Scheme, "ProjectController", t.EventRecorder)
return t.ProjectService.ReconcileProject(kubeSliceCtx, req)
}
11 changes: 11 additions & 0 deletions controllers/controller/serviceexportconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package controller

import (
"context"

"github.com/kubeslice/kubeslice-monitoring/pkg/events"
"go.uber.org/zap"

controllerv1alpha1 "github.com/kubeslice/kubeslice-controller/apis/controller/v1alpha1"
"github.com/kubeslice/kubeslice-controller/pkg/ha"
"github.com/kubeslice/kubeslice-controller/service"
"github.com/kubeslice/kubeslice-controller/util"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -36,10 +38,19 @@ type ServiceExportConfigReconciler struct {
ServiceExportConfigService service.IServiceExportConfigService
Log *zap.SugaredLogger
EventRecorder *events.EventRecorder
// LeaderElector gates mutating reconciles on cross-cluster leadership. It is
// nil-safe: a nil elector (HA not wired) behaves as standalone. See ADR #293.
LeaderElector *ha.ClusterLeaderElector
}

// Reconcile is a function to reconcile the ServiceExportConfig, ServiceExportConfigReconciler implements it
func (r *ServiceExportConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// HA write fence: only the Active hub (or a standalone controller) writes.
// A Standby evaluates this on every call and no-ops.
if r.LeaderElector != nil && !r.LeaderElector.IsLeader() {
r.Log.Info("standby mode, skipping reconcile")
return ctrl.Result{}, nil
}
kubeSliceCtx := util.PrepareKubeSliceControllersRequestContext(ctx, r.Client, r.Scheme, "ServiceExportConfigController", r.EventRecorder)
return r.ServiceExportConfigService.ReconcileServiceExportConfig(kubeSliceCtx, req)
}
Expand Down
10 changes: 10 additions & 0 deletions controllers/controller/sliceconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"go.uber.org/zap"

controllerv1alpha1 "github.com/kubeslice/kubeslice-controller/apis/controller/v1alpha1"
"github.com/kubeslice/kubeslice-controller/pkg/ha"
"github.com/kubeslice/kubeslice-controller/service"
"github.com/kubeslice/kubeslice-controller/util"

Expand All @@ -38,10 +39,19 @@ type SliceConfigReconciler struct {
SliceConfigService service.ISliceConfigService
Log *zap.SugaredLogger
EventRecorder *events.EventRecorder
// LeaderElector gates mutating reconciles on cross-cluster leadership. It is
// nil-safe: a nil elector (HA not wired) behaves as standalone. See ADR #293.
LeaderElector *ha.ClusterLeaderElector
}

// Reconcile is a function to reconcile the slice config, SliceConfigReconciler implements it
func (r *SliceConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// HA write fence: only the Active hub (or a standalone controller) writes.
// A Standby evaluates this on every call and no-ops.
if r.LeaderElector != nil && !r.LeaderElector.IsLeader() {
r.Log.Info("standby mode, skipping reconcile")
return ctrl.Result{}, nil
Comment on lines +49 to +53
}
kubeSliceCtx := util.PrepareKubeSliceControllersRequestContext(ctx, r.Client, r.Scheme, "SliceConfigController", r.EventRecorder)
return r.SliceConfigService.ReconcileSliceConfig(kubeSliceCtx, req)
}
Expand Down
11 changes: 11 additions & 0 deletions controllers/controller/sliceqosconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package controller

import (
"context"

"github.com/kubeslice/kubeslice-controller/pkg/ha"
"github.com/kubeslice/kubeslice-controller/service"
"github.com/kubeslice/kubeslice-controller/util"
"github.com/kubeslice/kubeslice-monitoring/pkg/events"
Expand All @@ -37,6 +39,9 @@ type SliceQoSConfigReconciler struct {
SliceQoSConfigService service.ISliceQoSConfigService
Log *zap.SugaredLogger
EventRecorder *events.EventRecorder
// LeaderElector gates mutating reconciles on cross-cluster leadership. It is
// nil-safe: a nil elector (HA not wired) behaves as standalone. See ADR #293.
LeaderElector *ha.ClusterLeaderElector
}

// SetupWithManager sets up the controller with the Manager.
Expand All @@ -48,6 +53,12 @@ func (r *SliceQoSConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {

// Reconcile is a function to reconcile the qos_profile, SliceQoSConfigReconciler implements it
func (r *SliceQoSConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// HA write fence: only the Active hub (or a standalone controller) writes.
// A Standby evaluates this on every call and no-ops.
if r.LeaderElector != nil && !r.LeaderElector.IsLeader() {
r.Log.Info("standby mode, skipping reconcile")
return ctrl.Result{}, nil
}
kubeSliceCtx := util.PrepareKubeSliceControllersRequestContext(ctx, r.Client, r.Scheme, "SliceQoSConfigController", r.EventRecorder)
return r.SliceQoSConfigService.ReconcileSliceQoSConfig(kubeSliceCtx, req)
}
Loading