Skip to content

Commit 2360eac

Browse files
committed
feat: remove node startup taint on driver start
Pods that mount SecretProviderClass volumes can be scheduled onto a node before the secrets-store CSI driver pod is running there, and fail to mount until it is. Cluster operators can now close this race by tainting nodes with secrets-store.csi.k8s.io/agent-not-ready at registration (for example via EKS Managed Node Group taints). The driver removes that taint from its own node once it starts, so workload pods only schedule after the driver is ready. The mechanism follows the aws-fsx-csi-driver implementation: a background goroutine reads the node name from KUBE_NODE_NAME, gets the Node, and patches out any taint with the matching key, retrying with exponential backoff. The JSON patch pairs a test op on /spec/taints with the replace so a concurrent taint update rejects the patch and the retry re-reads fresh state. On clusters that never apply the taint, or when KUBE_NODE_NAME is unset, this is a no-op. RBAC adds nodes get/patch to the ClusterRole (staging chart and kustomize; released copies promoted at release time).
1 parent 267a614 commit 2360eac

8 files changed

Lines changed: 438 additions & 0 deletions

File tree

cmd/secrets-store-csi-driver/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ func mainErr() error {
198198
reconciler.RunPatcher(ctx)
199199
}()
200200

201+
// Remove the node startup taint (if present) so workload pods can schedule
202+
// onto this node now that the driver is starting. Runs in the background and
203+
// no-ops on clusters that do not apply the taint.
204+
secretsstore.RemoveNotReadyTaintInBackground()
205+
201206
driver := secretsstore.NewSecretsStoreDriver(*driverName, *nodeID, *endpoint, providerClients, mgr.GetClient(), mgr.GetAPIReader(), *enableSecretRotation, *rotationPollInterval)
202207
driver.Run(ctx)
203208

config/rbac/role.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ rules:
1111
verbs:
1212
- create
1313
- patch
14+
- apiGroups:
15+
- ""
16+
resources:
17+
- nodes
18+
verbs:
19+
- get
20+
- patch
1421
- apiGroups:
1522
- ""
1623
resources:

controllers/secretproviderclasspodstatus_controller.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ func (r *SecretProviderClassPodStatusReconciler) ListOptionsLabelSelector() clie
215215
// +kubebuilder:rbac:groups=secrets-store.csi.x-k8s.io,resources=secretproviderclasses,verbs=get;list;watch
216216
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
217217
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
218+
// The nodes get/patch rule is not used by this reconciler; it is required by the
219+
// node startup taint removal in pkg/secrets-store/node_taint.go.
220+
// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;patch
218221
// +kubebuilder:rbac:groups="storage.k8s.io",resources=csidrivers,verbs=get;list;watch,resourceNames=secrets-store.csi.k8s.io
219222

220223
func (r *SecretProviderClassPodStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

docs/book/src/getting-started/installation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ Notably the following feature must be explicitly enabled:
3333

3434
For a list of customizable values that can be injected when invoking helm install, please see the [Helm chart configurations](https://github.com/kubernetes-sigs/secrets-store-csi-driver/tree/main/charts/secrets-store-csi-driver#configuration).
3535

36+
### Configure node startup taint
37+
38+
There are potential race conditions on node startup (especially when a node is first joining the cluster) where pods that rely on the Secrets Store CSI Driver can be scheduled onto a node and attempt to mount a `SecretProviderClass` volume before the driver has started up and become fully ready on that node. To close this race, the driver automatically removes a taint from its node on startup. Cluster administrators can taint their nodes when they join the cluster (and/or on startup) to prevent workload pods from being scheduled before the driver is ready.
39+
40+
This behavior is always on and requires no configuration on the driver side. To use it, taint your nodes with `secrets-store.csi.k8s.io/agent-not-ready` (any effect works, but `NoExecute` is recommended, e.g. `secrets-store.csi.k8s.io/agent-not-ready:NoExecute`). The driver's node `DaemonSet` already tolerates all taints, so its own pod still schedules and, once running, removes the taint so other pods can be scheduled. For example, EKS Managed Node Groups [support automatically tainting nodes](https://docs.aws.amazon.com/eks/latest/userguide/node-taints-managed-node-groups.html).
41+
42+
On clusters that never apply this taint the feature is a no-op. Removing the taint requires the driver's `ServiceAccount` to have `get` and `patch` on the `nodes` resource; the bundled RBAC grants this.
43+
3644
### [Alternatively] Deployment using yamls
3745

3846
```bash

manifest_staging/charts/secrets-store-csi-driver/templates/role.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ rules:
1414
verbs:
1515
- create
1616
- patch
17+
- apiGroups:
18+
- ""
19+
resources:
20+
- nodes
21+
verbs:
22+
- get
23+
- patch
1724
- apiGroups:
1825
- ""
1926
resources:

manifest_staging/deploy/rbac-secretproviderclass.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ rules:
1616
verbs:
1717
- create
1818
- patch
19+
- apiGroups:
20+
- ""
21+
resources:
22+
- nodes
23+
verbs:
24+
- get
25+
- patch
1926
- apiGroups:
2027
- ""
2128
resources:

pkg/secrets-store/node_taint.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package secretsstore
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"os"
23+
"time"
24+
25+
corev1 "k8s.io/api/core/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
k8stypes "k8s.io/apimachinery/pkg/types"
28+
"k8s.io/apimachinery/pkg/util/wait"
29+
"k8s.io/client-go/kubernetes"
30+
"k8s.io/client-go/rest"
31+
"k8s.io/klog/v2"
32+
)
33+
34+
// AgentNotReadyNodeTaintKey is the taint key the driver removes from its local
35+
// node once it has started. Operators (for example EKS Managed Node Groups) can
36+
// apply this taint to a node so that workload pods are not scheduled there until
37+
// the secrets-store CSI driver pod is running, avoiding the startup race where a
38+
// pod mounts a SecretProviderClass volume before the driver is ready.
39+
const AgentNotReadyNodeTaintKey = "secrets-store.csi.k8s.io/agent-not-ready"
40+
41+
// nodeNameEnvVar is the environment variable the DaemonSet populates from
42+
// spec.nodeName (also consumed as --nodeid). It identifies the local node whose
43+
// taint should be removed.
44+
const nodeNameEnvVar = "KUBE_NODE_NAME"
45+
46+
// kubernetesClientGetter lazily builds a Kubernetes clientset. It is a function
47+
// so tests can inject a fake clientset and exercise the failure path.
48+
type kubernetesClientGetter func() (kubernetes.Interface, error)
49+
50+
// inClusterClientGetter builds a clientset from the in-cluster config. It is the
51+
// production implementation passed to removeTaintInBackground.
52+
var inClusterClientGetter kubernetesClientGetter = func() (kubernetes.Interface, error) {
53+
config, err := rest.InClusterConfig()
54+
if err != nil {
55+
return nil, err
56+
}
57+
return kubernetes.NewForConfig(config)
58+
}
59+
60+
// taintRemovalBackoff is the exponential backoff for node taint removal retries.
61+
// Max delay across the steps is 0.5s * 2^9 = ~4 minutes.
62+
var taintRemovalBackoff = wait.Backoff{
63+
Duration: 500 * time.Millisecond,
64+
Factor: 2,
65+
Steps: 10,
66+
}
67+
68+
// jsonPatch is a single RFC 6902 JSON patch operation.
69+
type jsonPatch struct {
70+
OP string `json:"op,omitempty"`
71+
Path string `json:"path,omitempty"`
72+
Value interface{} `json:"value"`
73+
}
74+
75+
// RemoveNotReadyTaintInBackground removes the node startup taint
76+
// (AgentNotReadyNodeTaintKey) from the local node in a background goroutine,
77+
// retrying with exponential backoff. It is the production entry point and is safe
78+
// to call unconditionally: it no-ops on clusters that never apply the taint.
79+
func RemoveNotReadyTaintInBackground() {
80+
go removeTaintInBackground(inClusterClientGetter, removeNotReadyTaint)
81+
}
82+
83+
// removeTaintInBackground retries removalFunc with exponential backoff until it
84+
// succeeds or the backoff is exhausted. It is intended to be run as a goroutine
85+
// so driver startup is not blocked on the Kubernetes API being reachable.
86+
func removeTaintInBackground(clientGetter kubernetesClientGetter, removalFunc func(kubernetesClientGetter) error) {
87+
backoffErr := wait.ExponentialBackoff(taintRemovalBackoff, func() (bool, error) {
88+
if err := removalFunc(clientGetter); err != nil {
89+
klog.ErrorS(err, "Unexpected failure when attempting to remove node taint(s)")
90+
return false, nil
91+
}
92+
return true, nil
93+
})
94+
if backoffErr != nil {
95+
klog.ErrorS(backoffErr, "Retries exhausted, giving up attempting to remove node taint(s)")
96+
}
97+
}
98+
99+
// removeNotReadyTaint removes the AgentNotReadyNodeTaintKey taint from the local
100+
// node. It is a no-op (returns nil) when the node name is unknown or the client
101+
// cannot be built, so a cluster that never applies the taint is unaffected. It
102+
// returns an error only on transient API failures so the caller can retry.
103+
func removeNotReadyTaint(clientGetter kubernetesClientGetter) error {
104+
nodeName := os.Getenv(nodeNameEnvVar)
105+
if nodeName == "" {
106+
klog.V(4).InfoS("node name env var missing, skipping taint removal", "envVar", nodeNameEnvVar)
107+
return nil
108+
}
109+
110+
clientset, err := clientGetter()
111+
if err != nil {
112+
klog.V(4).InfoS("failed to setup k8s client, skipping taint removal", "error", err)
113+
return nil
114+
}
115+
116+
node, err := clientset.CoreV1().Nodes().Get(context.Background(), nodeName, metav1.GetOptions{})
117+
if err != nil {
118+
return err
119+
}
120+
121+
taintsToKeep := make([]corev1.Taint, 0, len(node.Spec.Taints))
122+
for _, taint := range node.Spec.Taints {
123+
if taint.Key != AgentNotReadyNodeTaintKey {
124+
taintsToKeep = append(taintsToKeep, taint)
125+
} else {
126+
klog.V(4).InfoS("queued taint for removal", "key", taint.Key, "effect", taint.Effect)
127+
}
128+
}
129+
130+
if len(taintsToKeep) == len(node.Spec.Taints) {
131+
klog.V(4).InfoS("no taints to remove on node, skipping taint removal", "node", nodeName)
132+
return nil
133+
}
134+
135+
// Use a test-and-replace patch so we never clobber a concurrent update to
136+
// the node's taints: if spec.taints changed since the Get, the test op fails
137+
// and the patch is rejected, and the backoff retry re-reads the node.
138+
patchRemoveTaints := []jsonPatch{
139+
{
140+
OP: "test",
141+
Path: "/spec/taints",
142+
Value: node.Spec.Taints,
143+
},
144+
{
145+
OP: "replace",
146+
Path: "/spec/taints",
147+
Value: taintsToKeep,
148+
},
149+
}
150+
151+
patch, err := json.Marshal(patchRemoveTaints)
152+
if err != nil {
153+
return err
154+
}
155+
156+
if _, err := clientset.CoreV1().Nodes().Patch(context.Background(), nodeName, k8stypes.JSONPatchType, patch, metav1.PatchOptions{}); err != nil {
157+
return err
158+
}
159+
160+
klog.InfoS("removed taint(s) from local node", "node", nodeName, "taintKey", AgentNotReadyNodeTaintKey)
161+
return nil
162+
}

0 commit comments

Comments
 (0)