Skip to content

Commit 68bb12e

Browse files
prad9192tekton-robot
authored andcommitted
Use PATCH instead of UPDATE in StopSidecars to avoid 409 conflicts
1 parent 4c29026 commit 68bb12e

File tree

3 files changed

+279
-31
lines changed

3 files changed

+279
-31
lines changed

examples/v1/pipelineruns/pipelinerun-with-task-timeout-override.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ spec:
2121
script: |
2222
#!/bin/sh
2323
echo "- Pipeline task timeout: 60s (defined in pipeline spec)"
24-
echo "- TaskRunSpecs override: 20s (defined in pipelinerun spec)"
25-
echo "- Actual task duration: 10s (within 20s override timeout)"
24+
echo "- TaskRunSpecs override: 40s (defined in pipelinerun spec)"
25+
echo "- Actual task duration: 10s (within 40s override timeout)"
2626
---
2727
apiVersion: tekton.dev/v1
2828
kind: Pipeline
@@ -50,4 +50,4 @@ spec:
5050
name: taskrun-timeout-override
5151
taskRunSpecs:
5252
- pipelineTaskName: long-running-task
53-
timeout: "20s" # 20s timeout (can't actually test with a timeout lesser than 10s else the task fails)
53+
timeout: "40s" # 40s timeout (accounts for image pull time + 10s sleep)

pkg/pod/entrypoint.go

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,43 @@ func init() {
271271
}
272272
}
273273

274+
// buildSidecarStopPatch creates a JSON Patch to replace sidecar container images with nop image
275+
func buildSidecarStopPatch(pod *corev1.Pod, nopImage string, ctx context.Context) ([]byte, error) {
276+
var patchOps []jsonpatch.JsonPatchOperation
277+
278+
// Iterate over container statuses to find running sidecars
279+
for _, s := range pod.Status.ContainerStatuses {
280+
// If the results-from is set to sidecar logs,
281+
// a sidecar container with name `sidecar-log-results` is injected by the reconciler.
282+
// Do not kill this sidecar. Let it exit gracefully.
283+
if config.FromContextOrDefaults(ctx).FeatureFlags.ResultExtractionMethod == config.ResultExtractionMethodSidecarLogs && s.Name == pipeline.ReservedResultsSidecarContainerName {
284+
continue
285+
}
286+
// Stop any running container that isn't a step.
287+
// An injected sidecar container might not have the
288+
// "sidecar-" prefix, so we can't just look for that prefix.
289+
if !IsContainerStep(s.Name) && s.State.Running != nil {
290+
// Find the corresponding container in the spec by name to get the correct index
291+
for i, c := range pod.Spec.Containers {
292+
if c.Name == s.Name && c.Image != nopImage {
293+
patchOps = append(patchOps, jsonpatch.JsonPatchOperation{
294+
Operation: "replace",
295+
Path: fmt.Sprintf("/spec/containers/%d/image", i),
296+
Value: nopImage,
297+
})
298+
break
299+
}
300+
}
301+
}
302+
}
303+
304+
if len(patchOps) == 0 {
305+
return nil, nil
306+
}
307+
308+
return json.Marshal(patchOps)
309+
}
310+
274311
// CancelPod cancels the pod
275312
func CancelPod(ctx context.Context, kubeClient kubernetes.Interface, namespace, podName string) error {
276313
// PATCH the Pod's annotations to replace the cancel annotation with the
@@ -296,43 +333,37 @@ func UpdateReady(ctx context.Context, kubeclient kubernetes.Interface, pod corev
296333
// StopSidecars updates sidecar containers in the Pod to a nop image, which
297334
// exits successfully immediately.
298335
func StopSidecars(ctx context.Context, nopImage string, kubeclient kubernetes.Interface, namespace, name string) (*corev1.Pod, error) {
299-
newPod, err := kubeclient.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
336+
pod, err := kubeclient.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
300337
if k8serrors.IsNotFound(err) {
301338
// return NotFound as-is, since the K8s error checks don't handle wrapping.
302339
return nil, err
303340
} else if err != nil {
304341
return nil, fmt.Errorf("error getting Pod %q when stopping sidecars: %w", name, err)
305342
}
306343

307-
updated := false
308-
if newPod.Status.Phase == corev1.PodRunning {
309-
for _, s := range newPod.Status.ContainerStatuses {
310-
// If the results-from is set to sidecar logs,
311-
// a sidecar container with name `sidecar-log-results` is injected by the reconiler.
312-
// Do not kill this sidecar. Let it exit gracefully.
313-
if config.FromContextOrDefaults(ctx).FeatureFlags.ResultExtractionMethod == config.ResultExtractionMethodSidecarLogs && s.Name == pipeline.ReservedResultsSidecarContainerName {
314-
continue
315-
}
316-
// Stop any running container that isn't a step.
317-
// An injected sidecar container might not have the
318-
// "sidecar-" prefix, so we can't just look for that
319-
// prefix.
320-
if !IsContainerStep(s.Name) && s.State.Running != nil {
321-
for j, c := range newPod.Spec.Containers {
322-
if c.Name == s.Name && c.Image != nopImage {
323-
updated = true
324-
newPod.Spec.Containers[j].Image = nopImage
325-
}
326-
}
327-
}
328-
}
344+
// Only attempt to stop sidecars if pod is running
345+
if pod.Status.Phase != corev1.PodRunning {
346+
return pod, nil
329347
}
330-
if updated {
331-
if newPod, err = kubeclient.CoreV1().Pods(newPod.Namespace).Update(ctx, newPod, metav1.UpdateOptions{}); err != nil {
332-
return nil, fmt.Errorf("error stopping sidecars of Pod %q: %w", name, err)
333-
}
348+
349+
// Build JSON Patch operations to replace sidecar images
350+
patchBytes, err := buildSidecarStopPatch(pod, nopImage, ctx)
351+
if err != nil {
352+
return nil, fmt.Errorf("error building patch for stopping sidecars of Pod %q: %w", name, err)
334353
}
335-
return newPod, nil
354+
355+
// If no sidecars need to be stopped, return early
356+
if patchBytes == nil {
357+
return pod, nil
358+
}
359+
360+
// PATCH the Pod's container images to stop sidecars, using the same pattern as UpdateReady and CancelPod
361+
patchedPod, err := kubeclient.CoreV1().Pods(namespace).Patch(ctx, name, types.JSONPatchType, patchBytes, metav1.PatchOptions{})
362+
if err != nil {
363+
return nil, fmt.Errorf("error stopping sidecars of Pod %q: %w", name, err)
364+
}
365+
366+
return patchedPod, nil
336367
}
337368

338369
// IsSidecarStatusRunning determines if any SidecarStatus on a TaskRun

pkg/pod/entrypoint_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929

3030
"github.com/google/go-cmp/cmp"
3131
corev1 "k8s.io/api/core/v1"
32+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
3233
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3334
"k8s.io/apimachinery/pkg/runtime"
3435
"k8s.io/apimachinery/pkg/selection"
@@ -1097,6 +1098,222 @@ func TestStopSidecars(t *testing.T) {
10971098
}
10981099
}
10991100

1101+
// TestStopSidecarsUsesPatch verifies that StopSidecars uses PATCH instead of UPDATE.
1102+
func TestStopSidecarsUsesPatch(t *testing.T) {
1103+
stepContainer := corev1.Container{
1104+
Name: stepPrefix + "my-step",
1105+
Image: "foo",
1106+
}
1107+
sidecarContainer := corev1.Container{
1108+
Name: sidecarPrefix + "my-sidecar",
1109+
Image: "original-sidecar-image",
1110+
}
1111+
injectedSidecar := corev1.Container{
1112+
Name: "injected-sidecar",
1113+
Image: "original-injected-image",
1114+
}
1115+
1116+
pod := &corev1.Pod{
1117+
ObjectMeta: metav1.ObjectMeta{
1118+
Name: "test-pod",
1119+
Namespace: "default",
1120+
ResourceVersion: "1000",
1121+
},
1122+
Spec: corev1.PodSpec{
1123+
Containers: []corev1.Container{stepContainer, sidecarContainer, injectedSidecar},
1124+
},
1125+
Status: corev1.PodStatus{
1126+
Phase: corev1.PodRunning,
1127+
ContainerStatuses: []corev1.ContainerStatus{{
1128+
Name: stepContainer.Name,
1129+
State: corev1.ContainerState{
1130+
Terminated: &corev1.ContainerStateTerminated{
1131+
ExitCode: 0,
1132+
},
1133+
},
1134+
}, {
1135+
Name: sidecarContainer.Name,
1136+
State: corev1.ContainerState{
1137+
Running: &corev1.ContainerStateRunning{
1138+
StartedAt: metav1.NewTime(time.Now()),
1139+
},
1140+
},
1141+
}, {
1142+
Name: injectedSidecar.Name,
1143+
State: corev1.ContainerState{
1144+
Running: &corev1.ContainerStateRunning{
1145+
StartedAt: metav1.NewTime(time.Now()),
1146+
},
1147+
},
1148+
}},
1149+
},
1150+
}
1151+
1152+
kubeclient := fakek8s.NewSimpleClientset(pod)
1153+
1154+
var patchCalled, updateCalled bool
1155+
var patchType string
1156+
var patchBytes []byte
1157+
1158+
kubeclient.PrependReactor("patch", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
1159+
patchAction := action.(k8stesting.PatchAction)
1160+
patchCalled = true
1161+
patchType = string(patchAction.GetPatchType())
1162+
patchBytes = patchAction.GetPatch()
1163+
1164+
patchedPod := pod.DeepCopy()
1165+
patchedPod.Spec.Containers[1].Image = nopImage
1166+
patchedPod.Spec.Containers[2].Image = nopImage
1167+
return true, patchedPod, nil
1168+
})
1169+
kubeclient.PrependReactor("update", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
1170+
updateCalled = true
1171+
return true, nil, nil
1172+
})
1173+
1174+
ctx, cancel := context.WithCancel(t.Context())
1175+
defer cancel()
1176+
1177+
got, err := StopSidecars(ctx, nopImage, kubeclient, pod.Namespace, pod.Name)
1178+
if err != nil {
1179+
t.Fatalf("StopSidecars failed: %v", err)
1180+
}
1181+
1182+
if !patchCalled {
1183+
t.Error("expected PATCH to be called")
1184+
}
1185+
if updateCalled {
1186+
t.Error("expected UPDATE not to be called")
1187+
}
1188+
1189+
if patchType != "application/json-patch+json" {
1190+
t.Errorf("expected patch type 'application/json-patch+json', got %q", patchType)
1191+
}
1192+
1193+
patchStr := string(patchBytes)
1194+
if !containsSubstr(patchStr, "/spec/containers/1/image") {
1195+
t.Errorf("patch should target container 1, got: %s", patchStr)
1196+
}
1197+
if !containsSubstr(patchStr, "/spec/containers/2/image") {
1198+
t.Errorf("patch should target container 2, got: %s", patchStr)
1199+
}
1200+
if containsSubstr(patchStr, "/spec/containers/0/image") {
1201+
t.Errorf("patch should not target step container, got: %s", patchStr)
1202+
}
1203+
1204+
if got.Spec.Containers[1].Image != nopImage {
1205+
t.Errorf("expected sidecar image %q, got %q", nopImage, got.Spec.Containers[1].Image)
1206+
}
1207+
if got.Spec.Containers[2].Image != nopImage {
1208+
t.Errorf("expected injected sidecar image %q, got %q", nopImage, got.Spec.Containers[2].Image)
1209+
}
1210+
if got.Spec.Containers[0].Image != stepContainer.Image {
1211+
t.Errorf("step container should be unchanged, got %q", got.Spec.Containers[0].Image)
1212+
}
1213+
}
1214+
1215+
// TestStopSidecarsWithConflictSimulation simulates concurrent pod updates
1216+
// that cause 409 conflicts with UPDATE but succeed with PATCH.
1217+
func TestStopSidecarsWithConflictSimulation(t *testing.T) {
1218+
stepContainer := corev1.Container{
1219+
Name: stepPrefix + "my-step",
1220+
Image: "foo",
1221+
}
1222+
sidecarContainer := corev1.Container{
1223+
Name: sidecarPrefix + "my-sidecar",
1224+
Image: "original-image",
1225+
}
1226+
1227+
pod := &corev1.Pod{
1228+
ObjectMeta: metav1.ObjectMeta{
1229+
Name: "test-pod",
1230+
Namespace: "default",
1231+
ResourceVersion: "1000",
1232+
},
1233+
Spec: corev1.PodSpec{
1234+
Containers: []corev1.Container{stepContainer, sidecarContainer},
1235+
},
1236+
Status: corev1.PodStatus{
1237+
Phase: corev1.PodRunning,
1238+
ContainerStatuses: []corev1.ContainerStatus{{
1239+
Name: stepContainer.Name,
1240+
State: corev1.ContainerState{
1241+
Terminated: &corev1.ContainerStateTerminated{},
1242+
},
1243+
}, {
1244+
Name: sidecarContainer.Name,
1245+
State: corev1.ContainerState{
1246+
Running: &corev1.ContainerStateRunning{
1247+
StartedAt: metav1.NewTime(time.Now()),
1248+
},
1249+
},
1250+
}},
1251+
},
1252+
}
1253+
1254+
kubeclient := fakek8s.NewSimpleClientset(pod)
1255+
1256+
podUpdated := false
1257+
1258+
kubeclient.PrependReactor("get", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
1259+
p := pod.DeepCopy()
1260+
if !podUpdated {
1261+
p.ResourceVersion = "1000"
1262+
podUpdated = true
1263+
} else {
1264+
p.ResourceVersion = "1001"
1265+
p.Status.ContainerStatuses[0].State.Terminated.FinishedAt = metav1.NewTime(time.Now())
1266+
}
1267+
return true, p, nil
1268+
})
1269+
1270+
kubeclient.PrependReactor("update", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
1271+
updateAction := action.(k8stesting.UpdateAction)
1272+
updatedPod := updateAction.GetObject().(*corev1.Pod)
1273+
1274+
if podUpdated && updatedPod.ResourceVersion == "1000" {
1275+
return true, nil, k8serrors.NewConflict(
1276+
corev1.Resource("pods"),
1277+
pod.Name,
1278+
errors.New("the object has been modified; please apply your changes to the latest version"),
1279+
)
1280+
}
1281+
return false, nil, nil
1282+
})
1283+
1284+
kubeclient.PrependReactor("patch", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) {
1285+
patchedPod := pod.DeepCopy()
1286+
patchedPod.Spec.Containers[1].Image = nopImage
1287+
patchedPod.ResourceVersion = "1002"
1288+
return true, patchedPod, nil
1289+
})
1290+
1291+
ctx, cancel := context.WithCancel(t.Context())
1292+
defer cancel()
1293+
1294+
got, err := StopSidecars(ctx, nopImage, kubeclient, pod.Namespace, pod.Name)
1295+
if err != nil {
1296+
if k8serrors.IsConflict(err) {
1297+
t.Fatalf("got 409 conflict, this indicates UPDATE is being used instead of PATCH: %v", err)
1298+
}
1299+
t.Fatalf("unexpected error: %v", err)
1300+
}
1301+
1302+
if got.Spec.Containers[1].Image != nopImage {
1303+
t.Errorf("expected sidecar image %q, got %q", nopImage, got.Spec.Containers[1].Image)
1304+
}
1305+
}
1306+
1307+
// Helper function to check if a string contains a substring
1308+
func containsSubstr(s, substr string) bool {
1309+
for i := 0; i <= len(s)-len(substr); i++ {
1310+
if s[i:i+len(substr)] == substr {
1311+
return true
1312+
}
1313+
}
1314+
return false
1315+
}
1316+
11001317
func TestCancelPod(t *testing.T) {
11011318
pod := &corev1.Pod{
11021319
ObjectMeta: metav1.ObjectMeta{

0 commit comments

Comments
 (0)