Skip to content

Commit 80df349

Browse files
Add optional restart-only predicate to inplace upgrade flow
Consumers occasionally need a DaemonSet's pods rolled to a new revision without the disruptive pod-eviction and drain sequence -- for example when a pod-template change does not affect the managed software (a label-only change) yet still changes the controller revision hash, so the node is otherwise driven through the full upgrade flow. Add an optional RestartOnlyPredicate hook, registered via WithRestartOnlyPredicate. In the inplace ProcessUpgradeRequiredNodes, when the predicate reports that a full upgrade is not needed, cordon the node and route it straight to pod-restart-required, skipping wait-for-jobs, pod-deletion, and drain. The pod is still restarted so the DaemonSet converges to the new revision. Cordoning keeps the node unschedulable if the restart fails, matching the full flow; the uncordon-required state uncordons it on success. The change is additive and backward compatible: a nil predicate (the default, and every existing consumer) preserves current behavior, and podInSyncWithDS is unchanged. If the predicate returns an error or the cordon fails, the node is kept in upgrade-required and retried on a later reconcile, with a Warning event recorded -- an upgrade is never started on an unknown answer. The predicate is not consulted for orphaned pods, nodes with an explicit upgrade-requested annotation, or nodes waiting for safe driver load -- the safe-load handshake relies on the full flow to evict workloads before the driver load is unblocked at pod-restart-required. The maxParallelUpgrades throttle applies to both paths. The upgrade-requested state is captured before the annotation is cleared, because the node provider re-fetches and overwrites the in-memory node object. Signed-off-by: Rajath Agasthya <ragasthya@nvidia.com>
1 parent fa6a364 commit 80df349

4 files changed

Lines changed: 254 additions & 7 deletions

File tree

pkg/upgrade/common_manager.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ func NewClusterUpgradeState() ClusterUpgradeState {
7979
return ClusterUpgradeState{NodeStates: make(map[string][]*NodeUpgradeState)}
8080
}
8181

82+
// RestartOnlyPredicate is used for a node whose driver pod is out-of-sync with
83+
// its DaemonSet. Returning true means the difference does not affect the installed
84+
// driver, so the node is cordoned and the driver pod restarted in place, skipping
85+
// pod-deletion (workload eviction) and drain; the consumer guarantees the running
86+
// driver does not need to change across the restart. Returning false (the default
87+
// when unset) routes the node through the full upgrade flow. Returning an error
88+
// keeps the node in upgrade-required to be retried on a later reconcile. It is
89+
// never called for orphaned pods, upgrade-requested nodes, or nodes waiting for
90+
// safe driver load.
91+
type RestartOnlyPredicate func(ctx context.Context, pod *corev1.Pod, ds *appsv1.DaemonSet) (bool, error)
92+
8293
// CommonUpgradeManagerImpl is an implementation of the CommonUpgradeStateManager interface.
8394
// It facilitates common logic implementation for both upgrade modes: in-place and requestor (e.g. maintenance OP).
8495
type CommonUpgradeManagerImpl struct {
@@ -97,6 +108,8 @@ type CommonUpgradeManagerImpl struct {
97108
// optional states
98109
podDeletionStateEnabled bool
99110
validationStateEnabled bool
111+
// optional: when set, route immaterial pod-template changes to a restart-only path
112+
restartOnlyPredicate RestartOnlyPredicate
100113
}
101114

102115
// NewCommonUpgradeStateManager creates a new instance of CommonUpgradeManagerImpl

pkg/upgrade/upgrade_inplace.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package upgrade
1919
import (
2020
"context"
2121

22+
corev1 "k8s.io/api/core/v1"
2223
"k8s.io/apimachinery/pkg/util/intstr"
2324

2425
"github.com/NVIDIA/k8s-operator-libs/api/upgrade/v1alpha1"
@@ -69,7 +70,8 @@ func (m *InplaceNodeStateManagerImpl) ProcessUpgradeRequiredNodes(
6970
"maximum nodes that can be unavailable", maxUnavailable)
7071

7172
for _, nodeState := range currentClusterState.NodeStates[UpgradeStateUpgradeRequired] {
72-
if m.IsUpgradeRequested(nodeState.Node) {
73+
upgradeRequested := m.IsUpgradeRequested(nodeState.Node)
74+
if upgradeRequested {
7375
// Make sure to remove the upgrade-requested annotation
7476
err := m.NodeUpgradeStateProvider.ChangeNodeUpgradeAnnotation(ctx, nodeState.Node,
7577
GetUpgradeRequestedAnnotationKey(), "null")
@@ -96,14 +98,62 @@ func (m *InplaceNodeStateManagerImpl) ProcessUpgradeRequiredNodes(
9698
}
9799
}
98100

99-
err := m.NodeUpgradeStateProvider.ChangeNodeUpgradeState(ctx, nodeState.Node, UpgradeStateCordonRequired)
101+
// Default to the full upgrade flow. A registered restart-only predicate can route an
102+
// out-of-sync node to an in-place driver pod restart; it is not used for orphaned pods,
103+
// nodes that explicitly requested an upgrade, or nodes waiting for safe driver load.
104+
targetState := UpgradeStateCordonRequired
105+
if m.restartOnlyPredicate != nil && !upgradeRequested && !nodeState.IsOrphanedPod() {
106+
// A node waiting for safe driver load must take the full flow, which evicts workloads
107+
// before the load is unblocked at pod-restart-required. Restart-only would skip that eviction.
108+
waitingForSafeLoad, serr := m.SafeDriverLoadManager.IsWaitingForSafeDriverLoad(ctx, nodeState.Node)
109+
if serr != nil {
110+
m.Log.V(consts.LogLevelError).Error(serr,
111+
"failed to check safe driver load status; node kept in upgrade-required for retry",
112+
"node", nodeState.Node.Name)
113+
logEventf(m.EventRecorder, nodeState.Node, corev1.EventTypeWarning, GetEventReason(),
114+
"Failed to check safe driver load status, will retry: %v", serr)
115+
continue
116+
}
117+
if !waitingForSafeLoad {
118+
restartOnly, perr := m.restartOnlyPredicate(ctx, nodeState.DriverPod, nodeState.DriverDaemonSet)
119+
if perr != nil {
120+
// On error, keep the node in upgrade-required and retry on the next
121+
// reconcile instead of starting a full upgrade.
122+
m.Log.V(consts.LogLevelError).Error(perr,
123+
"restart-only predicate failed; node kept in upgrade-required for retry",
124+
"node", nodeState.Node.Name)
125+
logEventf(m.EventRecorder, nodeState.Node, corev1.EventTypeWarning, GetEventReason(),
126+
"Failed to evaluate restart-only predicate, will retry: %v", perr)
127+
continue
128+
}
129+
if restartOnly {
130+
// Cordon the node so it stays unschedulable if the pod restart fails, as in
131+
// the full upgrade flow; the uncordon-required state uncordons it on success.
132+
if cerr := m.CordonManager.Cordon(ctx, nodeState.Node); cerr != nil {
133+
m.Log.V(consts.LogLevelError).Error(cerr,
134+
"failed to cordon node; node kept in upgrade-required for retry",
135+
"node", nodeState.Node.Name)
136+
logEventf(m.EventRecorder, nodeState.Node, corev1.EventTypeWarning, GetEventReason(),
137+
"Failed to cordon node for restart-only upgrade, will retry: %v", cerr)
138+
continue
139+
}
140+
targetState = UpgradeStatePodRestartRequired
141+
m.Log.V(consts.LogLevelInfo).Info(
142+
"Restart-only change detected; cordoning node and restarting driver pod "+
143+
"in place, skipping pod-deletion and drain",
144+
"node", nodeState.Node.Name)
145+
}
146+
}
147+
}
148+
149+
err := m.NodeUpgradeStateProvider.ChangeNodeUpgradeState(ctx, nodeState.Node, targetState)
100150
if err == nil {
101151
upgradesAvailable--
102-
m.Log.V(consts.LogLevelInfo).Info("Node waiting for cordon",
103-
"node", nodeState.Node.Name)
152+
m.Log.V(consts.LogLevelInfo).Info("Node moving to next upgrade state",
153+
"node", nodeState.Node.Name, "state", targetState)
104154
} else {
105155
m.Log.V(consts.LogLevelError).Error(
106-
err, "Failed to change node upgrade state", "state", UpgradeStateCordonRequired)
156+
err, "Failed to change node upgrade state", "state", targetState)
107157
return err
108158
}
109159
}

pkg/upgrade/upgrade_state.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ type ClusterUpgradeStateManager interface {
4040
// WithValidationEnabled provides an option to enable the optional 'validation' state
4141
// and pass a podSelector to specify which pods are performing the validation
4242
WithValidationEnabled(podSelector string) ClusterUpgradeStateManager
43+
// WithRestartOnlyPredicate registers an optional predicate (see RestartOnlyPredicate);
44+
// a nil predicate, the default, keeps the full upgrade flow for every out-of-sync node.
45+
WithRestartOnlyPredicate(predicate RestartOnlyPredicate) ClusterUpgradeStateManager
4346
// BuildState builds a point-in-time snapshot of the driver upgrade state in the cluster.
4447
BuildState(ctx context.Context, namespace string,
4548
driverLabels map[string]string) (*ClusterUpgradeState, error)
@@ -349,6 +352,14 @@ func (m *ClusterUpgradeStateManagerImpl) WithValidationEnabled(podSelector strin
349352
return m
350353
}
351354

355+
// WithRestartOnlyPredicate registers an optional restart-only predicate; a nil predicate
356+
// preserves the default full upgrade flow for every out-of-sync node.
357+
func (m *ClusterUpgradeStateManagerImpl) WithRestartOnlyPredicate(
358+
predicate RestartOnlyPredicate) ClusterUpgradeStateManager {
359+
m.restartOnlyPredicate = predicate
360+
return m
361+
}
362+
352363
// buildNodeUpgradeState creates a mapping between a node,
353364
// the driver POD running on them and the daemon set, controlling this pod
354365
func (m *ClusterUpgradeStateManagerImpl) buildNodeUpgradeState(

pkg/upgrade/upgrade_state_test.go

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ import (
3737
maintenancev1alpha1 "github.com/Mellanox/maintenance-operator/api/v1alpha1"
3838
k8serrors "k8s.io/apimachinery/pkg/api/errors"
3939

40-
v1alpha1 "github.com/NVIDIA/k8s-operator-libs/api/upgrade/v1alpha1"
41-
upgrade "github.com/NVIDIA/k8s-operator-libs/pkg/upgrade"
40+
"github.com/NVIDIA/k8s-operator-libs/api/upgrade/v1alpha1"
41+
"github.com/NVIDIA/k8s-operator-libs/pkg/upgrade"
4242
"github.com/NVIDIA/k8s-operator-libs/pkg/upgrade/mocks"
4343
)
4444

@@ -347,6 +347,179 @@ var _ = Describe("UpgradeStateManager tests", func() {
347347
Expect(stateCount[upgrade.UpgradeStateUpgradeRequired]).To(Equal(2))
348348
Expect(stateCount[upgrade.UpgradeStateCordonRequired]).To(Equal(maxParallelUpgrades))
349349
})
350+
When("a RestartOnlyPredicate is registered", func() {
351+
var (
352+
daemonSet *appsv1.DaemonSet
353+
outdatedPod *corev1.Pod
354+
)
355+
BeforeEach(func() {
356+
daemonSet = &appsv1.DaemonSet{ObjectMeta: v1.ObjectMeta{}}
357+
// pod revision hash differs from the mocked DS hash ("test-hash-12345") -> out of sync
358+
outdatedPod = &corev1.Pod{ObjectMeta: v1.ObjectMeta{
359+
Labels: map[string]string{upgrade.PodControllerRevisionHashLabelKey: "test-hash-outdated"}}}
360+
})
361+
362+
It("cordons the node and routes it to PodRestartRequired when the predicate returns true", func() {
363+
var gotPod *corev1.Pod
364+
var gotDS *appsv1.DaemonSet
365+
stateManagerInterface.WithRestartOnlyPredicate(
366+
func(_ context.Context, pod *corev1.Pod, ds *appsv1.DaemonSet) (bool, error) {
367+
gotPod, gotDS = pod, ds
368+
return true, nil
369+
})
370+
localCordonManager := mocks.CordonManager{}
371+
localCordonManager.On("Cordon", mock.Anything, mock.Anything).Return(nil)
372+
stateManager.CordonManager = &localCordonManager
373+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
374+
clusterState := upgrade.NewClusterUpgradeState()
375+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
376+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
377+
}
378+
379+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
380+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
381+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStatePodRestartRequired))
382+
localCordonManager.AssertCalled(GinkgoT(), "Cordon", mock.Anything, node)
383+
// predicate received this node's driver pod and owning DaemonSet
384+
Expect(gotPod).To(Equal(outdatedPod))
385+
Expect(gotDS).To(Equal(daemonSet))
386+
})
387+
388+
It("leaves the node in UpgradeRequired for retry when the cordon fails", func() {
389+
stateManagerInterface.WithRestartOnlyPredicate(
390+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
391+
return true, nil
392+
})
393+
localCordonManager := mocks.CordonManager{}
394+
localCordonManager.On("Cordon", mock.Anything, mock.Anything).Return(fmt.Errorf("cordon failure"))
395+
stateManager.CordonManager = &localCordonManager
396+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
397+
clusterState := upgrade.NewClusterUpgradeState()
398+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
399+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
400+
}
401+
402+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
403+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
404+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateUpgradeRequired))
405+
})
406+
407+
It("routes the node to CordonRequired when the predicate returns false", func() {
408+
stateManagerInterface.WithRestartOnlyPredicate(
409+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
410+
return false, nil
411+
})
412+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
413+
clusterState := upgrade.NewClusterUpgradeState()
414+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
415+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
416+
}
417+
418+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
419+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
420+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateCordonRequired))
421+
})
422+
423+
It("leaves the node in UpgradeRequired for retry when the predicate returns an error", func() {
424+
stateManagerInterface.WithRestartOnlyPredicate(
425+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
426+
return true, fmt.Errorf("predicate failure")
427+
})
428+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
429+
clusterState := upgrade.NewClusterUpgradeState()
430+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
431+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
432+
}
433+
434+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
435+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
436+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateUpgradeRequired))
437+
})
438+
439+
It("does not invoke the predicate for an orphaned pod", func() {
440+
called := false
441+
stateManagerInterface.WithRestartOnlyPredicate(
442+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
443+
called = true
444+
return true, nil
445+
})
446+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
447+
clusterState := upgrade.NewClusterUpgradeState()
448+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
449+
{Node: node, DriverPod: &corev1.Pod{}, DriverDaemonSet: nil}, // orphaned
450+
}
451+
452+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
453+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
454+
Expect(called).To(BeFalse())
455+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateCordonRequired))
456+
})
457+
458+
It("does not invoke the predicate for an upgrade-requested node", func() {
459+
called := false
460+
stateManagerInterface.WithRestartOnlyPredicate(
461+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
462+
called = true
463+
return true, nil
464+
})
465+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
466+
node.Annotations[upgrade.GetUpgradeRequestedAnnotationKey()] = "true"
467+
clusterState := upgrade.NewClusterUpgradeState()
468+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
469+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
470+
}
471+
472+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
473+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
474+
Expect(called).To(BeFalse())
475+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateCordonRequired))
476+
})
477+
478+
It("does not invoke the predicate for a node waiting for safe driver load", func() {
479+
called := false
480+
stateManagerInterface.WithRestartOnlyPredicate(
481+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
482+
called = true
483+
return true, nil
484+
})
485+
node := nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired)
486+
node.Annotations[upgrade.GetUpgradeDriverWaitForSafeLoadAnnotationKey()] = "true"
487+
clusterState := upgrade.NewClusterUpgradeState()
488+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = []*upgrade.NodeUpgradeState{
489+
{Node: node, DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
490+
}
491+
492+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState,
493+
&v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true})).To(Succeed())
494+
Expect(called).To(BeFalse())
495+
Expect(getNodeUpgradeState(node)).To(Equal(upgrade.UpgradeStateCordonRequired))
496+
})
497+
498+
It("honors the maxParallelUpgrades throttle for restart-only nodes", func() {
499+
const maxParallelUpgrades = 1
500+
stateManagerInterface.WithRestartOnlyPredicate(
501+
func(_ context.Context, _ *corev1.Pod, _ *appsv1.DaemonSet) (bool, error) {
502+
return true, nil
503+
})
504+
nodeStates := []*upgrade.NodeUpgradeState{
505+
{Node: nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired),
506+
DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
507+
{Node: nodeWithUpgradeState(upgrade.UpgradeStateUpgradeRequired),
508+
DriverPod: outdatedPod, DriverDaemonSet: daemonSet},
509+
}
510+
clusterState := upgrade.NewClusterUpgradeState()
511+
clusterState.NodeStates[upgrade.UpgradeStateUpgradeRequired] = nodeStates
512+
policy := &v1alpha1.DriverUpgradePolicySpec{AutoUpgrade: true, MaxParallelUpgrades: maxParallelUpgrades}
513+
514+
Expect(stateManagerInterface.ApplyState(testCtx, &clusterState, policy)).To(Succeed())
515+
stateCount := make(map[string]int)
516+
for i := range nodeStates {
517+
stateCount[getNodeUpgradeState(nodeStates[i].Node)]++
518+
}
519+
Expect(stateCount[upgrade.UpgradeStatePodRestartRequired]).To(Equal(maxParallelUpgrades))
520+
Expect(stateCount[upgrade.UpgradeStateUpgradeRequired]).To(Equal(1))
521+
})
522+
})
350523
It("UpgradeStateManager should start additional upgrades if maxParallelUpgrades limit is not reached", func() {
351524
const maxParallelUpgrades = 4
352525

0 commit comments

Comments
 (0)