From ced051b72f3abb467fa57eb2b85a0e7aedcf4936 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Mon, 10 Aug 2026 15:36:14 -0400 Subject: [PATCH] Fix restore-wait init container ignoring pod-level securityContext (#10047) restore-wait's securityContext fallback chain checked the fs-restore ConfigMap, then the first container's SecurityContext, then hardcoded runAsUser 1000. It never consulted pod.Spec.SecurityContext, so pods that set identity only at the pod level got a helper running as uid 1000 regardless of the workload's actual uid. On volumes where restored content is owner-only-visible to a non-1000 uid, the helper's stat on the done-file returns EACCES forever and the pod deadlocks at Init:0/1. Add pod-level spec.securityContext.runAsUser/runAsGroup as a fallback between the container-level check and the hardcoded default, since the workload's own identity is the one that can read what it restored. Defer to the pod's own RunAsNonRoot setting when runAsUser is 0, since the hardcoded RunAsNonRoot: true would otherwise contradict a root uid. Also add a test case covering both container-level and pod-level SecurityContext set together, confirming container-level still wins. Fixes #10046 Signed-off-by: Tiger Kaovilai --- changelogs/unreleased/10047-kaovilai | 1 + .../actions/pod_volume_restore_action.go | 19 ++ .../actions/pod_volume_restore_action_test.go | 205 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 changelogs/unreleased/10047-kaovilai diff --git a/changelogs/unreleased/10047-kaovilai b/changelogs/unreleased/10047-kaovilai new file mode 100644 index 000000000..6d96bdede --- /dev/null +++ b/changelogs/unreleased/10047-kaovilai @@ -0,0 +1 @@ +Fix restore-wait init container ignoring pod-level securityContext, falling back to hardcoded runAsUser 1000 instead of the workload's own uid/gid, causing fs-backup restores to deadlock at Init:0/1 on owner-restricted volumes diff --git a/pkg/restore/actions/pod_volume_restore_action.go b/pkg/restore/actions/pod_volume_restore_action.go index 5f2b3db3e..cbfcbfb35 100644 --- a/pkg/restore/actions/pod_volume_restore_action.go +++ b/pkg/restore/actions/pod_volume_restore_action.go @@ -198,6 +198,25 @@ func (a *PodVolumeRestoreAction) Execute(input *velero.RestoreItemActionExecuteI securityContext = *pod.Spec.Containers[0].SecurityContext.DeepCopy() securityContextSet = true } + // if no configmap or container-level securityContext is set, fall back to the pod-level + // spec.securityContext runAsUser/runAsGroup: the workload's own identity is the one that + // wrote the restored files, so it's the one that can read them back + if !securityContextSet && pod.Spec.SecurityContext != nil && + (pod.Spec.SecurityContext.RunAsUser != nil || pod.Spec.SecurityContext.RunAsGroup != nil) { + securityContext = defaultSecurityCtx() + if pod.Spec.SecurityContext.RunAsUser != nil { + securityContext.RunAsUser = pod.Spec.SecurityContext.RunAsUser + // defaultSecurityCtx() hardcodes RunAsNonRoot: true, which contradicts a pod-level + // RunAsUser of 0 (root); defer to the pod's own RunAsNonRoot setting in that case + if *pod.Spec.SecurityContext.RunAsUser == 0 { + securityContext.RunAsNonRoot = pod.Spec.SecurityContext.RunAsNonRoot + } + } + if pod.Spec.SecurityContext.RunAsGroup != nil { + securityContext.RunAsGroup = pod.Spec.SecurityContext.RunAsGroup + } + securityContextSet = true + } if !securityContextSet { securityContext = defaultSecurityCtx() } diff --git a/pkg/restore/actions/pod_volume_restore_action_test.go b/pkg/restore/actions/pod_volume_restore_action_test.go index bc9662ab7..614a5d1be 100644 --- a/pkg/restore/actions/pod_volume_restore_action_test.go +++ b/pkg/restore/actions/pod_volume_restore_action_test.go @@ -156,6 +156,155 @@ func TestPodVolumeRestoreActionExecute(t *testing.T) { defaultRestoreHelperImage := "velero/velero:v1.0" + podLevelUID := int64(999) + podLevelGID := int64(999) + podLevelSecurityContext := corev1api.SecurityContext{ + AllowPrivilegeEscalation: boolptr.False(), + Capabilities: &corev1api.Capabilities{ + Drop: []corev1api.Capability{"ALL"}, + }, + SeccompProfile: &corev1api.SeccompProfile{ + Type: corev1api.SeccompProfileTypeRuntimeDefault, + }, + RunAsUser: &podLevelUID, + RunAsGroup: &podLevelGID, + RunAsNonRoot: boolptr.True(), + } + + podWithPodLevelSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + Result() + podWithPodLevelSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &podLevelUID, RunAsGroup: &podLevelGID} + + wantPodWithPodLevelSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + InitContainers( + newRestoreInitContainerBuilder(defaultRestoreHelperImage, ""). + Resources(&resourceReqs). + SecurityContext(&podLevelSecurityContext). + VolumeMounts(builder.ForVolumeMount("myvol", "/restores/myvol").Result()). + Command([]string{"/velero-restore-helper"}).Result()). + Result() + wantPodWithPodLevelSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &podLevelUID, RunAsGroup: &podLevelGID} + + podLevelRootUID := int64(0) + podLevelRootSecurityContext := corev1api.SecurityContext{ + AllowPrivilegeEscalation: boolptr.False(), + Capabilities: &corev1api.Capabilities{ + Drop: []corev1api.Capability{"ALL"}, + }, + SeccompProfile: &corev1api.SeccompProfile{ + Type: corev1api.SeccompProfileTypeRuntimeDefault, + }, + RunAsUser: &podLevelRootUID, + } + + podWithPodLevelRootSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + Result() + podWithPodLevelRootSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &podLevelRootUID} + + wantPodWithPodLevelRootSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + InitContainers( + newRestoreInitContainerBuilder(defaultRestoreHelperImage, ""). + Resources(&resourceReqs). + SecurityContext(&podLevelRootSecurityContext). + VolumeMounts(builder.ForVolumeMount("myvol", "/restores/myvol").Result()). + Command([]string{"/velero-restore-helper"}).Result()). + Result() + wantPodWithPodLevelRootSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &podLevelRootUID} + + podLevelGroupOnlyGID := int64(777) + podLevelGroupOnlySecurityContext := corev1api.SecurityContext{ + AllowPrivilegeEscalation: boolptr.False(), + Capabilities: &corev1api.Capabilities{ + Drop: []corev1api.Capability{"ALL"}, + }, + SeccompProfile: &corev1api.SeccompProfile{ + Type: corev1api.SeccompProfileTypeRuntimeDefault, + }, + RunAsUser: &id, + RunAsGroup: &podLevelGroupOnlyGID, + RunAsNonRoot: boolptr.True(), + } + + podWithPodLevelGroupOnlySecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + Result() + podWithPodLevelGroupOnlySecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsGroup: &podLevelGroupOnlyGID} + + wantPodWithPodLevelGroupOnlySecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + InitContainers( + newRestoreInitContainerBuilder(defaultRestoreHelperImage, ""). + Resources(&resourceReqs). + SecurityContext(&podLevelGroupOnlySecurityContext). + VolumeMounts(builder.ForVolumeMount("myvol", "/restores/myvol").Result()). + Command([]string{"/velero-restore-helper"}).Result()). + Result() + wantPodWithPodLevelGroupOnlySecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsGroup: &podLevelGroupOnlyGID} + + bothLevelsPodUID := int64(500) + bothLevelsContainerUID := int64(999) + bothLevelsContainerSecurityContext := corev1api.SecurityContext{ + AllowPrivilegeEscalation: boolptr.False(), + Capabilities: &corev1api.Capabilities{ + Drop: []corev1api.Capability{"ALL"}, + }, + SeccompProfile: &corev1api.SeccompProfile{ + Type: corev1api.SeccompProfileTypeRuntimeDefault, + }, + RunAsUser: &bothLevelsContainerUID, + RunAsNonRoot: boolptr.True(), + } + + podWithBothLevelsSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + Containers( + builder.ForContainer("app-container", "app-image"). + SecurityContext(&bothLevelsContainerSecurityContext).Result()). + Result() + podWithBothLevelsSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &bothLevelsPodUID} + + wantPodWithBothLevelsSecurityContext := builder.ForPod("ns-1", "my-pod"). + ObjectMeta(builder.WithAnnotations("snapshot.velero.io/myvol", "")). + Volumes( + builder.ForVolume("myvol").PersistentVolumeClaimSource("pvc-1").Result(), + ). + Containers( + builder.ForContainer("app-container", "app-image"). + SecurityContext(&bothLevelsContainerSecurityContext).Result()). + InitContainers( + newRestoreInitContainerBuilder(defaultRestoreHelperImage, ""). + Resources(&resourceReqs). + SecurityContext(&bothLevelsContainerSecurityContext). + VolumeMounts(builder.ForVolumeMount("myvol", "/restores/myvol").Result()). + Command([]string{"/velero-restore-helper"}).Result()). + Result() + wantPodWithBothLevelsSecurityContext.Spec.SecurityContext = &corev1api.PodSecurityContext{RunAsUser: &bothLevelsPodUID} + tests := []struct { name string pod *corev1api.Pod @@ -350,6 +499,62 @@ func TestPodVolumeRestoreActionExecute(t *testing.T) { VolumeMounts(builder.ForVolumeMount("myvol", "/restores/myvol").Result()). Command([]string{"/velero-restore-helper"}).Result()).Result(), }, + { + name: "Restoring pod with pod-level securityContext (no container-level SecurityContext) uses pod-level runAsUser/runAsGroup for the restore initContainer", + pod: podWithPodLevelSecurityContext, + podVolumeBackups: []runtime.Object{ + builder.ForPodVolumeBackup(veleroNs, "pvb-1"). + PodName("my-pod"). + PodNamespace("ns-1"). + Volume("myvol"). + ObjectMeta(builder.WithLabels(velerov1api.BackupNameLabel, backupName)). + SnapshotID("foo"). + Result(), + }, + want: wantPodWithPodLevelSecurityContext, + }, + { + name: "Restoring pod with pod-level securityContext.runAsUser=0 does not force RunAsNonRoot on the restore initContainer", + pod: podWithPodLevelRootSecurityContext, + podVolumeBackups: []runtime.Object{ + builder.ForPodVolumeBackup(veleroNs, "pvb-1"). + PodName("my-pod"). + PodNamespace("ns-1"). + Volume("myvol"). + ObjectMeta(builder.WithLabels(velerov1api.BackupNameLabel, backupName)). + SnapshotID("foo"). + Result(), + }, + want: wantPodWithPodLevelRootSecurityContext, + }, + { + name: "Restoring pod with pod-level securityContext.runAsGroup only (no runAsUser) still applies the group to the restore initContainer", + pod: podWithPodLevelGroupOnlySecurityContext, + podVolumeBackups: []runtime.Object{ + builder.ForPodVolumeBackup(veleroNs, "pvb-1"). + PodName("my-pod"). + PodNamespace("ns-1"). + Volume("myvol"). + ObjectMeta(builder.WithLabels(velerov1api.BackupNameLabel, backupName)). + SnapshotID("foo"). + Result(), + }, + want: wantPodWithPodLevelGroupOnlySecurityContext, + }, + { + name: "Restoring pod with both container-level and pod-level SecurityContext set uses the container-level SecurityContext for the restore initContainer (container-level takes priority)", + pod: podWithBothLevelsSecurityContext, + podVolumeBackups: []runtime.Object{ + builder.ForPodVolumeBackup(veleroNs, "pvb-1"). + PodName("my-pod"). + PodNamespace("ns-1"). + Volume("myvol"). + ObjectMeta(builder.WithLabels(velerov1api.BackupNameLabel, backupName)). + SnapshotID("foo"). + Result(), + }, + want: wantPodWithBothLevelsSecurityContext, + }, { name: "pod volume backups in a different namespace are ignored when looking for matches due to namespace scoping", pod: builder.ForPod("ns-1", "my-pod").