Skip to content

Commit 45a5f73

Browse files
authored
Merge branch 'main' into xrfxlp/1597
2 parents 7a1bb59 + bae5b1c commit 45a5f73

5 files changed

Lines changed: 410 additions & 142 deletions

File tree

commons/pkg/kubeclient/nodepatch.go

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"context"
2020
"encoding/json"
2121
"fmt"
22+
"reflect"
2223
"sync"
2324
"time"
2425

@@ -50,7 +51,15 @@ func (p *NodePatcher) Patch(
5051
cached *v1.Node,
5152
mutate func(*v1.Node) error,
5253
) (bool, error) {
53-
current, err := p.currentNode(ctx, nodes, nodeName, cached)
54+
var current *v1.Node
55+
56+
err := retry.OnError(nodePatchBackoff(), isRetryableNodePatchError, func() error {
57+
var err error
58+
59+
current, err = p.currentNode(ctx, nodes, nodeName, cached)
60+
61+
return err
62+
})
5463
if err != nil {
5564
return false, err
5665
}
@@ -85,11 +94,13 @@ func (p *NodePatcher) Patch(
8594
if errors.IsConflict(err) {
8695
patchErr := err
8796

88-
current, err = nodes.Get(ctx, nodeName, metav1.GetOptions{})
97+
refreshed, err := nodes.Get(ctx, nodeName, metav1.GetOptions{})
8998
if err != nil {
9099
return fmt.Errorf("refresh node %q after patch conflict: %w", nodeName, err)
91100
}
92101

102+
current = refreshed
103+
93104
return fmt.Errorf("patch node %q: %w", nodeName, patchErr)
94105
}
95106

@@ -134,8 +145,6 @@ func (p *NodePatcher) currentNode(
134145
return nil, fmt.Errorf("refresh node %q while pending write is not in cache: %w", nodeName, err)
135146
}
136147

137-
p.pendingVersions.CompareAndDelete(nodeName, writtenVersionValue)
138-
139148
return current, nil
140149
}
141150

@@ -156,40 +165,36 @@ func isRetryableNodePatchError(err error) bool {
156165
errors.IsServiceUnavailable(err)
157166
}
158167

159-
// NodeMergePatch builds an RFC 7386 JSON merge patch carrying the label and
160-
// annotation differences between original and modified. It returns a nil patch when
161-
// the two already agree, so callers can skip the write instead of spending an API
162-
// call on a no-op.
168+
// NodeMergePatch builds an RFC 7386 JSON merge patch carrying differences in labels,
169+
// annotations, taints, and unschedulable state. It returns a nil patch when the two
170+
// nodes already agree, so callers can skip a no-op write.
163171
//
164-
// CreateTwoWayMergePatch compares metadata-only projections of the two Nodes.
165-
// Excluding every other field from both inputs ensures an informer projection cannot
166-
// patch its gaps back over the live object.
172+
// CreateTwoWayMergePatch compares projections containing only the fields this helper
173+
// supports. Excluding every other field from both inputs ensures an informer
174+
// projection cannot patch its gaps back over the live object.
167175
//
168-
// Spec fields such as taints and unschedulable are deliberately out of scope: a merge
169-
// patch replaces a list wholesale, so patching taints from a projected Node whose Spec
170-
// had been cleared would silently drop every taint on the real object.
176+
// Taints are emitted only when the caller changed them. A projected Node whose Spec
177+
// is empty on both sides therefore cannot erase taints from the real object.
171178
func NodeMergePatch(original, modified *v1.Node) ([]byte, error) {
172-
originalMetadata := &v1.Node{
173-
ObjectMeta: metav1.ObjectMeta{
174-
Labels: original.Labels,
175-
Annotations: original.Annotations,
176-
},
177-
}
178-
modifiedMetadata := &v1.Node{
179-
ObjectMeta: metav1.ObjectMeta{
180-
Labels: modified.Labels,
181-
Annotations: modified.Annotations,
182-
},
179+
originalProjection := projectNodePatchableFields(original)
180+
modifiedProjection := projectNodePatchableFields(modified)
181+
182+
specChanged := !reflect.DeepEqual(original.Spec.Taints, modified.Spec.Taints) ||
183+
original.Spec.Unschedulable != modified.Spec.Unschedulable
184+
if specChanged {
185+
// Lists in spec, such as taints, are replaced wholesale. ResourceVersion
186+
// prevents a stale list from overwriting a concurrent update.
187+
modifiedProjection.ResourceVersion = original.ResourceVersion
183188
}
184189

185-
originalJSON, err := json.Marshal(originalMetadata)
190+
originalJSON, err := json.Marshal(originalProjection)
186191
if err != nil {
187-
return nil, fmt.Errorf("marshal original metadata for node %q: %w", original.Name, err)
192+
return nil, fmt.Errorf("marshal original patch projection for node %q: %w", original.Name, err)
188193
}
189194

190-
modifiedJSON, err := json.Marshal(modifiedMetadata)
195+
modifiedJSON, err := json.Marshal(modifiedProjection)
191196
if err != nil {
192-
return nil, fmt.Errorf("marshal modified metadata for node %q: %w", original.Name, err)
197+
return nil, fmt.Errorf("marshal modified patch projection for node %q: %w", original.Name, err)
193198
}
194199

195200
patch, err := strategicpatch.CreateTwoWayMergePatch(originalJSON, modifiedJSON, v1.Node{})
@@ -203,3 +208,18 @@ func NodeMergePatch(original, modified *v1.Node) ([]byte, error) {
203208

204209
return patch, nil
205210
}
211+
212+
// projectNodePatchableFields restricts patch generation to the Node fields this
213+
// helper intentionally supports, preventing callbacks from patching unrelated fields.
214+
func projectNodePatchableFields(node *v1.Node) *v1.Node {
215+
return &v1.Node{
216+
ObjectMeta: metav1.ObjectMeta{
217+
Labels: node.Labels,
218+
Annotations: node.Annotations,
219+
},
220+
Spec: v1.NodeSpec{
221+
Taints: node.Spec.Taints,
222+
Unschedulable: node.Spec.Unschedulable,
223+
},
224+
}
225+
}

commons/pkg/kubeclient/nodepatch_test.go

Lines changed: 74 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ func TestNodePatcher_CachedNode_UsesPatchAndSkipsNoOp(t *testing.T) {
7979
assert.Empty(t, clientset.Actions())
8080
}
8181

82-
func TestNodePatcher_PreviousWriteNotInCache_ReadsLiveNode(t *testing.T) {
83-
current := node(map[string]string{"a": "1"}, nil)
82+
func TestNodePatcher_NoOpBetweenWrites_KeepsReadingLiveNode(t *testing.T) {
83+
current := node(nil, map[string]string{"events": "base"})
8484
clientset := fake.NewSimpleClientset(current.DeepCopy())
8585
var patcher NodePatcher
8686

@@ -90,7 +90,7 @@ func TestNodePatcher_PreviousWriteNotInCache_ReadsLiveNode(t *testing.T) {
9090
current.Name,
9191
current,
9292
func(node *v1.Node) error {
93-
node.Labels["b"] = "2"
93+
node.Annotations["events"] += "|first"
9494
return nil
9595
},
9696
)
@@ -111,12 +111,29 @@ func TestNodePatcher_PreviousWriteNotInCache_ReadsLiveNode(t *testing.T) {
111111
require.Len(t, clientset.Actions(), 1)
112112
assert.Equal(t, "get", clientset.Actions()[0].GetVerb())
113113

114+
clientset.ClearActions()
115+
changed, err = patcher.Patch(
116+
context.Background(),
117+
clientset.CoreV1().Nodes(),
118+
current.Name,
119+
stale,
120+
func(node *v1.Node) error {
121+
node.Annotations["events"] += "|second"
122+
return nil
123+
},
124+
)
125+
require.NoError(t, err)
126+
assert.True(t, changed)
127+
require.Len(t, clientset.Actions(), 2)
128+
assert.Equal(t, "get", clientset.Actions()[0].GetVerb())
129+
assert.Equal(t, "patch", clientset.Actions()[1].GetVerb())
130+
114131
updated, err := clientset.CoreV1().Nodes().Get(t.Context(), current.Name, metav1.GetOptions{})
115132
require.NoError(t, err)
116-
assert.Equal(t, "2", updated.Labels["b"])
133+
assert.Equal(t, "base|first|second", updated.Annotations["events"])
117134
}
118135

119-
func TestNodePatcher_LiveReadFailure_PreservesPendingVersion(t *testing.T) {
136+
func TestNodePatcher_LiveReadRetriesTransientFailure(t *testing.T) {
120137
current := node(map[string]string{"a": "1"}, nil)
121138
clientset := fake.NewSimpleClientset(current.DeepCopy())
122139
var patcher NodePatcher
@@ -128,22 +145,12 @@ func TestNodePatcher_LiveReadFailure_PreservesPendingVersion(t *testing.T) {
128145
clientset.PrependReactor("get", "nodes", func(k8stesting.Action) (bool, runtime.Object, error) {
129146
getAttempts++
130147
if getAttempts == 1 {
131-
return true, nil, assert.AnError
148+
return true, nil, apierrors.NewTooManyRequests("try again", 0)
132149
}
133150

134151
return false, nil, nil
135152
})
136153

137-
_, err := patcher.Patch(
138-
context.Background(),
139-
clientset.CoreV1().Nodes(),
140-
current.Name,
141-
stale,
142-
func(*v1.Node) error { return nil },
143-
)
144-
require.ErrorIs(t, err, assert.AnError)
145-
assert.ErrorContains(t, err, `refresh node "node-1" while pending write is not in cache`)
146-
147154
changed, err := patcher.Patch(
148155
context.Background(),
149156
clientset.CoreV1().Nodes(),
@@ -198,12 +205,34 @@ func TestNodePatcher_Conflict_RefreshesLiveNodeBeforeRetry(t *testing.T) {
198205
assert.Equal(t, "true", updated.Labels["desired"])
199206
}
200207

201-
func TestNodeMergePatch_MetadataChanges_ReturnsExpectedPatch(t *testing.T) {
208+
func TestNodeMergePatch_ReturnsExpectedPatch(t *testing.T) {
209+
resourceVersionOriginal := node(nil, nil)
210+
resourceVersionOriginal.ResourceVersion = "42"
211+
resourceVersionModified := resourceVersionOriginal.DeepCopy()
212+
resourceVersionModified.Spec.Unschedulable = true
213+
214+
projected := &v1.Node{
215+
ObjectMeta: metav1.ObjectMeta{
216+
Name: "node-1",
217+
ResourceVersion: "1",
218+
Labels: map[string]string{"gpu": "true"},
219+
Annotations: map[string]string{"kept": "yes"},
220+
},
221+
}
222+
projectedModified := projected.DeepCopy()
223+
projectedModified.Labels["driver.installed"] = "true"
224+
225+
specOriginal := node(nil, nil)
226+
specModified := specOriginal.DeepCopy()
227+
specModified.Spec.Unschedulable = true
228+
specModified.Spec.Taints = []v1.Taint{{Key: "held", Effect: v1.TaintEffectNoSchedule}}
229+
202230
tests := []struct {
203231
name string
204232
original *v1.Node
205233
modified *v1.Node
206234
expected string
235+
excluded []string
207236
}{
208237
{
209238
name: "no change produces no patch",
@@ -253,6 +282,31 @@ func TestNodeMergePatch_MetadataChanges_ReturnsExpectedPatch(t *testing.T) {
253282
modified: node(map[string]string{"a": "1"}, nil),
254283
expected: `{"metadata":{"labels":{"a":"1"}}}`,
255284
},
285+
{
286+
name: "spec change includes original resource version",
287+
original: resourceVersionOriginal,
288+
modified: resourceVersionModified,
289+
expected: `{"metadata":{"resourceVersion":"42"},"spec":{"unschedulable":true}}`,
290+
},
291+
{
292+
name: "projected fields remain absent",
293+
original: projected,
294+
modified: projectedModified,
295+
expected: `{"metadata":{"labels":{"driver.installed":"true"}}}`,
296+
excluded: []string{"annotations", "spec"},
297+
},
298+
{
299+
name: "sets spec fields",
300+
original: specOriginal,
301+
modified: specModified,
302+
expected: `{"metadata":{"resourceVersion":"1"},"spec":{"taints":[{"key":"held","effect":"NoSchedule"}],"unschedulable":true}}`,
303+
},
304+
{
305+
name: "clears spec fields",
306+
original: specModified,
307+
modified: specOriginal,
308+
expected: `{"metadata":{"resourceVersion":"1"},"spec":{"taints":null,"unschedulable":null}}`,
309+
},
256310
}
257311

258312
for _, tt := range tests {
@@ -266,49 +320,9 @@ func TestNodeMergePatch_MetadataChanges_ReturnsExpectedPatch(t *testing.T) {
266320
}
267321

268322
assert.JSONEq(t, tt.expected, string(patch))
323+
for _, field := range tt.excluded {
324+
assert.NotContains(t, string(patch), field)
325+
}
269326
})
270327
}
271328
}
272-
273-
// TestNodeMergePatchLeavesProjectedFieldsAlone pins the reason the patch is built key
274-
// by key. Informer caches often hold a projected Node — the labeler's transform keeps
275-
// only one annotation and clears Spec entirely — and a patch derived from that
276-
// projection must not describe the fields the projection dropped, or it would erase
277-
// them on the real object.
278-
func TestNodeMergePatch_ProjectedFields_LeavesThemAlone(t *testing.T) {
279-
projected := &v1.Node{
280-
ObjectMeta: metav1.ObjectMeta{
281-
Name: "node-1",
282-
ResourceVersion: "1",
283-
Labels: map[string]string{"gpu": "true"},
284-
Annotations: map[string]string{"kept": "yes"},
285-
},
286-
}
287-
288-
modified := projected.DeepCopy()
289-
modified.Labels["driver.installed"] = "true"
290-
291-
patch, err := NodeMergePatch(projected, modified)
292-
require.NoError(t, err)
293-
294-
assert.JSONEq(t,
295-
`{"metadata":{"labels":{"driver.installed":"true"}}}`,
296-
string(patch),
297-
)
298-
assert.NotContains(t, string(patch), "annotations",
299-
"an untouched annotation must not appear in the patch")
300-
assert.NotContains(t, string(patch), "spec",
301-
"a cleared Spec must never reach the patch, or real taints would be dropped")
302-
}
303-
304-
func TestNodeMergePatch_SpecChanges_ReturnsNoPatch(t *testing.T) {
305-
original := node(nil, nil)
306-
modified := original.DeepCopy()
307-
modified.Spec.Unschedulable = true
308-
modified.Spec.Taints = []v1.Taint{{Key: "held", Effect: v1.TaintEffectNoSchedule}}
309-
310-
patch, err := NodeMergePatch(original, modified)
311-
require.NoError(t, err)
312-
313-
assert.Nil(t, patch, "spec is out of scope until a caller needs it")
314-
}

0 commit comments

Comments
 (0)