From fb3f94bc884ad33d49b7a68a8b927a191c7ec999 Mon Sep 17 00:00:00 2001 From: Christian Schlichtherle Date: Thu, 14 May 2026 19:03:20 +0200 Subject: [PATCH 1/4] Fix DataUploadDeleteAction creating CMs for foreign DataUploads When a backup tarball incidentally contains DataUpload CRs that belong to a different backup (common when a schedule includes the velero namespace where DataUploads live), DataUploadDeleteAction.Execute used to create a "-info" ConfigMap labeled with the *executing* backup's name instead of the DataUpload's true owning backup. The ConfigMap is created with Create-only semantics, so the wrong label is never corrected. deleteMovedSnapshots in the backup-deletion controller looks up these ConfigMaps by velero.io/backup-name to discover which Kopia snapshots to delete. With the wrong label, the real owning backup's expiry pass finds no ConfigMaps for its DataUploads and silently leaves their Kopia snapshots in object storage, leaking data over time. Fix: in DataUploadDeleteAction.Execute, compare the DataUpload's velero.io/backup-name label against input.Backup.Name (using label.GetValidName to handle DNS-1035 truncation for long backup names). If the label is present and differs, skip the DataUpload entirely; this prevents the over-eager creation of misnamed ConfigMaps without changing behavior for DataUploads that legitimately belong to the executing backup, or for legacy DataUploads with no backup-name label. Refs: #9472 Signed-off-by: Christian Schlichtherle --- .../unreleased/9472-christian-schlichtherle | 1 + pkg/datamover/dataupload_delete_action.go | 14 ++ .../dataupload_delete_action_test.go | 144 ++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 changelogs/unreleased/9472-christian-schlichtherle create mode 100644 pkg/datamover/dataupload_delete_action_test.go diff --git a/changelogs/unreleased/9472-christian-schlichtherle b/changelogs/unreleased/9472-christian-schlichtherle new file mode 100644 index 000000000..069423559 --- /dev/null +++ b/changelogs/unreleased/9472-christian-schlichtherle @@ -0,0 +1 @@ +Fix DataUploadDeleteAction creating snapshot-info ConfigMaps labeled with the wrong backup name when a DataUpload CR from another backup is incidentally captured in the backup tarball, which caused Kopia snapshots to be leaked in object storage on expiry of the real owning backup. diff --git a/pkg/datamover/dataupload_delete_action.go b/pkg/datamover/dataupload_delete_action.go index 1c09a20a2..bdba37b7b 100644 --- a/pkg/datamover/dataupload_delete_action.go +++ b/pkg/datamover/dataupload_delete_action.go @@ -14,6 +14,7 @@ import ( velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" velerov2alpha1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1" + "github.com/vmware-tanzu/velero/pkg/label" "github.com/vmware-tanzu/velero/pkg/plugin/velero" repotypes "github.com/vmware-tanzu/velero/pkg/repository/types" ) @@ -35,6 +36,19 @@ func (d *DataUploadDeleteAction) Execute(input *velero.DeleteItemActionExecuteIn if err := runtime.DefaultUnstructuredConverter.FromUnstructured(input.Item.UnstructuredContent(), &du); err != nil { return errors.WithStack(errors.Wrapf(err, "failed to convert input.Item from unstructured")) } + // Skip DataUploads that do not belong to the backup being deleted. The + // backup tarball may incidentally include DataUpload CRs from the velero + // namespace that belong to a different backup (e.g. when an hourly + // schedule with snapshotMoveData=false captures the velero namespace + // containing a daily schedule's DataUploads). Creating a snapshot-info + // ConfigMap labeled with the wrong backup name causes the real owning + // backup's deleteMovedSnapshots query to miss it, leaking the Kopia + // snapshot in the object store. + if owner := du.Labels[velerov1.BackupNameLabel]; owner != "" && owner != label.GetValidName(input.Backup.Name) { + d.logger.Infof("Skipping DataUpload %s/%s: belongs to backup %q, not %q", + du.Namespace, du.Name, owner, input.Backup.Name) + return nil + } cm := genConfigmap(input.Backup, *du) if cm == nil { // will not fail the backup deletion diff --git a/pkg/datamover/dataupload_delete_action_test.go b/pkg/datamover/dataupload_delete_action_test.go new file mode 100644 index 000000000..f941243e3 --- /dev/null +++ b/pkg/datamover/dataupload_delete_action_test.go @@ -0,0 +1,144 @@ +/* +Copyright the Velero contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamover + +import ( + "fmt" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1api "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + velerov2alpha1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1" + "github.com/vmware-tanzu/velero/pkg/builder" + "github.com/vmware-tanzu/velero/pkg/plugin/velero" + velerotest "github.com/vmware-tanzu/velero/pkg/test" +) + +func toUnstructured(t *testing.T, du *velerov2alpha1.DataUpload) runtime.Unstructured { + t.Helper() + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(du) + require.NoError(t, err) + return &unstructured.Unstructured{Object: m} +} + +func newCompletedDataUpload(name, ownerBackup string) *velerov2alpha1.DataUpload { + du := &velerov2alpha1.DataUpload{ + TypeMeta: metav1.TypeMeta{ + APIVersion: velerov2alpha1.SchemeGroupVersion.String(), + Kind: "DataUpload", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "velero", + Name: name, + }, + Spec: velerov2alpha1.DataUploadSpec{ + SnapshotType: velerov2alpha1.SnapshotTypeCSI, + SourcePVC: "my-pvc", + SourceNamespace: "app", + BackupStorageLocation: "default", + DataMover: "velero", + }, + Status: velerov2alpha1.DataUploadStatus{ + Phase: velerov2alpha1.DataUploadPhaseCompleted, + SnapshotID: "kopia-snapshot-id", + }, + } + if ownerBackup != "" { + du.Labels = map[string]string{velerov1.BackupNameLabel: ownerBackup} + } + return du +} + +func TestDataUploadDeleteActionAppliesTo(t *testing.T) { + a := NewDataUploadDeleteAction(logrus.StandardLogger(), nil) + selector, err := a.AppliesTo() + require.NoError(t, err) + require.Equal(t, velero.ResourceSelector{IncludedResources: []string{"datauploads.velero.io"}}, selector) +} + +func TestDataUploadDeleteActionExecute(t *testing.T) { + tests := []struct { + name string + duName string + duOwnerBackup string // value placed in velero.io/backup-name label on the DataUpload + executingBackup string // name of the Backup being deleted (input.Backup.Name) + wantConfigMap bool + }{ + { + name: "DataUpload owned by the executing backup creates a snapshot-info ConfigMap", + duName: "daily-backup-abcde", + duOwnerBackup: "daily-backup", + executingBackup: "daily-backup", + wantConfigMap: true, + }, + { + name: "DataUpload owned by a different backup is skipped (no ConfigMap created)", + duName: "daily-backup-abcde", + duOwnerBackup: "daily-backup", + executingBackup: "hourly-backup", + wantConfigMap: false, + }, + { + name: "DataUpload with no backup-name label falls through (legacy behavior preserved)", + duName: "legacy-du", + duOwnerBackup: "", + executingBackup: "some-backup", + wantConfigMap: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + crClient := velerotest.NewFakeControllerRuntimeClient(t) + action := NewDataUploadDeleteAction(logrus.StandardLogger(), crClient) + + du := newCompletedDataUpload(tc.duName, tc.duOwnerBackup) + backup := builder.ForBackup("velero", tc.executingBackup).StorageLocation("default").Result() + + err := action.Execute(&velero.DeleteItemActionExecuteInput{ + Item: toUnstructured(t, du), + Backup: backup, + }) + require.NoError(t, err) + + cm := &corev1api.ConfigMap{} + getErr := crClient.Get(t.Context(), crclient.ObjectKey{ + Namespace: backup.Namespace, + Name: fmt.Sprintf("%s-info", du.Name), + }, cm) + + if tc.wantConfigMap { + require.NoError(t, getErr, "expected snapshot-info ConfigMap to be created") + assert.Equal(t, tc.executingBackup, cm.Labels[velerov1.BackupNameLabel]) + assert.Equal(t, "true", cm.Labels[velerov1.DataUploadSnapshotInfoLabel]) + } else { + require.Error(t, getErr) + assert.True(t, apierrors.IsNotFound(getErr), + "expected no ConfigMap to be created for foreign DataUpload, but got: %v", getErr) + } + }) + } +} From 8f6c563c4d09a79b95e9534fcf3ceb2a40b42af4 Mon Sep 17 00:00:00 2001 From: Christian Schlichtherle Date: Fri, 15 May 2026 08:10:19 +0200 Subject: [PATCH 2/4] Warn instead of silently skipping foreign DataUploads Velero does not support self-protection: the velero namespace must never be captured in a backup tarball. When it is, the tarball can contain DataUpload CRs belonging to other backups, and the previous revision of this change silently swallowed that case in the DataUploadDeleteAction. Per maintainer feedback, the action should make the misconfiguration detectable rather than silent. Emit a warn-level log naming the DataUpload, its owning backup-name label, and the executing backup, and call out that the velero namespace should be excluded from schedules. Continue to skip the snapshot-info ConfigMap creation so that a mislabeled CM does not mask the real owning backup's snapshot on deletion. The test for the foreign-backup case now also asserts the warn is emitted via a logrus test hook. Signed-off-by: Christian Schlichtherle --- pkg/datamover/dataupload_delete_action.go | 27 ++++++++----- .../dataupload_delete_action_test.go | 39 +++++++++++++++---- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/pkg/datamover/dataupload_delete_action.go b/pkg/datamover/dataupload_delete_action.go index bdba37b7b..9a550a1f9 100644 --- a/pkg/datamover/dataupload_delete_action.go +++ b/pkg/datamover/dataupload_delete_action.go @@ -36,17 +36,24 @@ func (d *DataUploadDeleteAction) Execute(input *velero.DeleteItemActionExecuteIn if err := runtime.DefaultUnstructuredConverter.FromUnstructured(input.Item.UnstructuredContent(), &du); err != nil { return errors.WithStack(errors.Wrapf(err, "failed to convert input.Item from unstructured")) } - // Skip DataUploads that do not belong to the backup being deleted. The - // backup tarball may incidentally include DataUpload CRs from the velero - // namespace that belong to a different backup (e.g. when an hourly - // schedule with snapshotMoveData=false captures the velero namespace - // containing a daily schedule's DataUploads). Creating a snapshot-info - // ConfigMap labeled with the wrong backup name causes the real owning - // backup's deleteMovedSnapshots query to miss it, leaking the Kopia - // snapshot in the object store. + // Detect DataUploads that do not belong to the backup being deleted. + // Velero does not support self-protection: the velero namespace should + // never be captured in a backup tarball. When it is (e.g. an operator + // schedule covers the velero namespace), the tarball can contain + // DataUpload CRs belonging to *other* backups. Creating a snapshot-info + // ConfigMap labeled with the executing backup's name in that case + // mislabels the snapshot and causes the real owning backup's + // deleteMovedSnapshots query to miss it, leaking the Kopia snapshot in + // the object store. Log a warning so misconfigured installs are visible, + // and skip the snapshot-info ConfigMap creation to avoid mislabeling. if owner := du.Labels[velerov1.BackupNameLabel]; owner != "" && owner != label.GetValidName(input.Backup.Name) { - d.logger.Infof("Skipping DataUpload %s/%s: belongs to backup %q, not %q", - du.Namespace, du.Name, owner, input.Backup.Name) + d.logger.Warnf( + "DataUpload %q belongs to backup %q but is being deleted under backup %q; "+ + "this almost always means the velero namespace was included in a backup tarball. "+ + "Velero does not support self-protection — exclude the velero namespace from your schedules. "+ + "Skipping snapshot-info ConfigMap creation to avoid mislabeling.", + du.Name, owner, input.Backup.Name, + ) return nil } cm := genConfigmap(input.Backup, *du) diff --git a/pkg/datamover/dataupload_delete_action_test.go b/pkg/datamover/dataupload_delete_action_test.go index f941243e3..35af628bb 100644 --- a/pkg/datamover/dataupload_delete_action_test.go +++ b/pkg/datamover/dataupload_delete_action_test.go @@ -18,9 +18,11 @@ package datamover import ( "fmt" + "strings" "testing" "github.com/sirupsen/logrus" + logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1api "k8s.io/api/core/v1" @@ -81,11 +83,12 @@ func TestDataUploadDeleteActionAppliesTo(t *testing.T) { func TestDataUploadDeleteActionExecute(t *testing.T) { tests := []struct { - name string - duName string - duOwnerBackup string // value placed in velero.io/backup-name label on the DataUpload - executingBackup string // name of the Backup being deleted (input.Backup.Name) - wantConfigMap bool + name string + duName string + duOwnerBackup string // value placed in velero.io/backup-name label on the DataUpload + executingBackup string // name of the Backup being deleted (input.Backup.Name) + wantConfigMap bool + wantWarn bool // whether a warn-level log about a foreign DataUpload is expected }{ { name: "DataUpload owned by the executing backup creates a snapshot-info ConfigMap", @@ -93,13 +96,15 @@ func TestDataUploadDeleteActionExecute(t *testing.T) { duOwnerBackup: "daily-backup", executingBackup: "daily-backup", wantConfigMap: true, + wantWarn: false, }, { - name: "DataUpload owned by a different backup is skipped (no ConfigMap created)", + name: "DataUpload owned by a different backup is skipped and a warning is logged", duName: "daily-backup-abcde", duOwnerBackup: "daily-backup", executingBackup: "hourly-backup", wantConfigMap: false, + wantWarn: true, }, { name: "DataUpload with no backup-name label falls through (legacy behavior preserved)", @@ -107,13 +112,16 @@ func TestDataUploadDeleteActionExecute(t *testing.T) { duOwnerBackup: "", executingBackup: "some-backup", wantConfigMap: true, + wantWarn: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { crClient := velerotest.NewFakeControllerRuntimeClient(t) - action := NewDataUploadDeleteAction(logrus.StandardLogger(), crClient) + logger, hook := logrustest.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + action := NewDataUploadDeleteAction(logger, crClient) du := newCompletedDataUpload(tc.duName, tc.duOwnerBackup) backup := builder.ForBackup("velero", tc.executingBackup).StorageLocation("default").Result() @@ -139,6 +147,23 @@ func TestDataUploadDeleteActionExecute(t *testing.T) { assert.True(t, apierrors.IsNotFound(getErr), "expected no ConfigMap to be created for foreign DataUpload, but got: %v", getErr) } + + // The action must surface foreign-backup DataUploads as warnings so + // operators who accidentally included the velero namespace in a + // backup can detect the misconfiguration from logs, instead of + // having the case silently swallowed. + var sawForeignWarn bool + for _, entry := range hook.AllEntries() { + if entry.Level == logrus.WarnLevel && + strings.Contains(entry.Message, "velero namespace") && + strings.Contains(entry.Message, tc.duName) { + sawForeignWarn = true + break + } + } + assert.Equal(t, tc.wantWarn, sawForeignWarn, + "unexpected foreign-backup warn log presence (want=%v, got=%v); entries=%v", + tc.wantWarn, sawForeignWarn, hook.AllEntries()) }) } } From 2f19c3158b3bb26c7c05c8b9905a529dd26040cf Mon Sep 17 00:00:00 2001 From: Christian Schlichtherle Date: Thu, 21 May 2026 11:50:21 +0200 Subject: [PATCH 3/4] Also skip snapshot-info CM when DataUpload has no owner label Per review feedback on #9791, the previous revision still let a DataUpload with an empty velero.io/backup-name label fall through to genConfigmap, creating a ConfigMap that deleteMovedSnapshots can never match back to a snapshot. The CM is useless and only adds etcd churn. Treat the missing-label case the same way as the foreign-owner case: warn and skip the ConfigMap creation. Use a distinct warn message so operators can tell the two misconfiguration classes apart in logs (missing-label vs. owner mismatch from a captured velero namespace). Test for the missing-label case is updated to assert no ConfigMap is created and a warn is emitted. The warn assertion is generalized to match the per-case message substring instead of a fixed string. Signed-off-by: Christian Schlichtherle --- pkg/datamover/dataupload_delete_action.go | 40 ++++++++--- .../dataupload_delete_action_test.go | 72 ++++++++++--------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/pkg/datamover/dataupload_delete_action.go b/pkg/datamover/dataupload_delete_action.go index 9a550a1f9..6b36e1068 100644 --- a/pkg/datamover/dataupload_delete_action.go +++ b/pkg/datamover/dataupload_delete_action.go @@ -36,17 +36,35 @@ func (d *DataUploadDeleteAction) Execute(input *velero.DeleteItemActionExecuteIn if err := runtime.DefaultUnstructuredConverter.FromUnstructured(input.Item.UnstructuredContent(), &du); err != nil { return errors.WithStack(errors.Wrapf(err, "failed to convert input.Item from unstructured")) } - // Detect DataUploads that do not belong to the backup being deleted. - // Velero does not support self-protection: the velero namespace should - // never be captured in a backup tarball. When it is (e.g. an operator - // schedule covers the velero namespace), the tarball can contain - // DataUpload CRs belonging to *other* backups. Creating a snapshot-info - // ConfigMap labeled with the executing backup's name in that case - // mislabels the snapshot and causes the real owning backup's - // deleteMovedSnapshots query to miss it, leaking the Kopia snapshot in - // the object store. Log a warning so misconfigured installs are visible, - // and skip the snapshot-info ConfigMap creation to avoid mislabeling. - if owner := du.Labels[velerov1.BackupNameLabel]; owner != "" && owner != label.GetValidName(input.Backup.Name) { + // Only create a snapshot-info ConfigMap when the DataUpload's owning + // backup (its velero.io/backup-name label) matches the backup currently + // being deleted. Two other cases reach this code path and must be + // skipped, because the resulting CM would be unmatchable and only adds + // etcd churn: + // + // 1. The label is missing. We have no verifiable owner, so a CM created + // with the executing backup's label is a guess that deleteMovedSnapshots + // cannot rely on. + // 2. The label names a different backup. Velero does not support + // self-protection, so this almost always means the velero namespace + // was captured in a backup tarball and the DataUpload CR belongs to + // an unrelated backup. Creating a CM labeled with the executing + // backup mislabels the snapshot and causes the real owning backup's + // deleteMovedSnapshots query to miss it, leaking the Kopia snapshot + // in the object store. + // + // Both cases warn so misconfigured installs surface in logs. + owner := du.Labels[velerov1.BackupNameLabel] + switch { + case owner == "": + d.logger.Warnf( + "DataUpload %q has no %q label, so its owning backup cannot be verified; "+ + "skipping snapshot-info ConfigMap creation because a CM without a verifiable owner "+ + "cannot be matched back to its snapshot at backup deletion time.", + du.Name, velerov1.BackupNameLabel, + ) + return nil + case owner != label.GetValidName(input.Backup.Name): d.logger.Warnf( "DataUpload %q belongs to backup %q but is being deleted under backup %q; "+ "this almost always means the velero namespace was included in a backup tarball. "+ diff --git a/pkg/datamover/dataupload_delete_action_test.go b/pkg/datamover/dataupload_delete_action_test.go index 35af628bb..cdbf4b006 100644 --- a/pkg/datamover/dataupload_delete_action_test.go +++ b/pkg/datamover/dataupload_delete_action_test.go @@ -83,36 +83,36 @@ func TestDataUploadDeleteActionAppliesTo(t *testing.T) { func TestDataUploadDeleteActionExecute(t *testing.T) { tests := []struct { - name string - duName string - duOwnerBackup string // value placed in velero.io/backup-name label on the DataUpload - executingBackup string // name of the Backup being deleted (input.Backup.Name) - wantConfigMap bool - wantWarn bool // whether a warn-level log about a foreign DataUpload is expected + name string + duName string + duOwnerBackup string // value placed in velero.io/backup-name label on the DataUpload + executingBackup string // name of the Backup being deleted (input.Backup.Name) + wantConfigMap bool + wantWarnContains string // substring expected in a warn-level log entry; empty means no warn expected }{ { - name: "DataUpload owned by the executing backup creates a snapshot-info ConfigMap", - duName: "daily-backup-abcde", - duOwnerBackup: "daily-backup", - executingBackup: "daily-backup", - wantConfigMap: true, - wantWarn: false, + name: "DataUpload owned by the executing backup creates a snapshot-info ConfigMap", + duName: "daily-backup-abcde", + duOwnerBackup: "daily-backup", + executingBackup: "daily-backup", + wantConfigMap: true, + wantWarnContains: "", }, { - name: "DataUpload owned by a different backup is skipped and a warning is logged", - duName: "daily-backup-abcde", - duOwnerBackup: "daily-backup", - executingBackup: "hourly-backup", - wantConfigMap: false, - wantWarn: true, + name: "DataUpload owned by a different backup is skipped and a warning is logged", + duName: "daily-backup-abcde", + duOwnerBackup: "daily-backup", + executingBackup: "hourly-backup", + wantConfigMap: false, + wantWarnContains: "velero namespace", }, { - name: "DataUpload with no backup-name label falls through (legacy behavior preserved)", - duName: "legacy-du", - duOwnerBackup: "", - executingBackup: "some-backup", - wantConfigMap: true, - wantWarn: false, + name: "DataUpload with no backup-name label is skipped and a warning is logged", + duName: "unlabeled-du", + duOwnerBackup: "", + executingBackup: "some-backup", + wantConfigMap: false, + wantWarnContains: "cannot be verified", }, } @@ -148,22 +148,24 @@ func TestDataUploadDeleteActionExecute(t *testing.T) { "expected no ConfigMap to be created for foreign DataUpload, but got: %v", getErr) } - // The action must surface foreign-backup DataUploads as warnings so - // operators who accidentally included the velero namespace in a - // backup can detect the misconfiguration from logs, instead of - // having the case silently swallowed. - var sawForeignWarn bool + // The action must surface DataUploads it cannot generate a useful + // snapshot-info ConfigMap for as warnings, so operators who + // accidentally included the velero namespace in a backup (or + // otherwise produced DataUploads without a verifiable owner) can + // detect the misconfiguration from logs instead of having the + // case silently swallowed. + var sawWarn bool for _, entry := range hook.AllEntries() { if entry.Level == logrus.WarnLevel && - strings.Contains(entry.Message, "velero namespace") && - strings.Contains(entry.Message, tc.duName) { - sawForeignWarn = true + strings.Contains(entry.Message, tc.duName) && + (tc.wantWarnContains == "" || strings.Contains(entry.Message, tc.wantWarnContains)) { + sawWarn = true break } } - assert.Equal(t, tc.wantWarn, sawForeignWarn, - "unexpected foreign-backup warn log presence (want=%v, got=%v); entries=%v", - tc.wantWarn, sawForeignWarn, hook.AllEntries()) + assert.Equal(t, tc.wantWarnContains != "", sawWarn, + "unexpected warn log presence (wantContains=%q, got=%v); entries=%v", + tc.wantWarnContains, sawWarn, hook.AllEntries()) }) } } From b91d34065b84feb7ee71bbd4285d8a9547f3f9a0 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Thu, 21 May 2026 22:05:40 -0400 Subject: [PATCH 4/4] Add changelog for unreleased version 9791 Signed-off-by: Tiger Kaovilai --- ...{9472-christian-schlichtherle => 9791-christian-schlichtherle} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelogs/unreleased/{9472-christian-schlichtherle => 9791-christian-schlichtherle} (100%) diff --git a/changelogs/unreleased/9472-christian-schlichtherle b/changelogs/unreleased/9791-christian-schlichtherle similarity index 100% rename from changelogs/unreleased/9472-christian-schlichtherle rename to changelogs/unreleased/9791-christian-schlichtherle