diff --git a/changelogs/unreleased/10082-adam-jian-zhang b/changelogs/unreleased/10082-adam-jian-zhang new file mode 100644 index 000000000..e704ed6a7 --- /dev/null +++ b/changelogs/unreleased/10082-adam-jian-zhang @@ -0,0 +1 @@ +Add restore.velero.io/must-include-additional-items so RestoreItemActions can opt in to bypassing global restore filters for AdditionalItems (mirrors the backup-side must-include annotation; no default behavior change for existing restores/plugins) diff --git a/hack/build-image/Dockerfile b/hack/build-image/Dockerfile index aa725da03..4f34ba470 100644 --- a/hack/build-image/Dockerfile +++ b/hack/build-image/Dockerfile @@ -102,10 +102,5 @@ RUN ARCH=$(go env GOARCH) && \ # release API/CDN, which has been returning intermittent/persistent HTTP 504s. RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 -# install kubectl -RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/$(go env GOARCH)/kubectl -RUN chmod +x ./kubectl -RUN mv ./kubectl /usr/local/bin - # Fix the "dubious ownership" issue from git when running goreleaser.sh RUN echo "[safe] \n\t directory = *" > /.gitconfig diff --git a/pkg/apis/velero/v1/labels_annotations.go b/pkg/apis/velero/v1/labels_annotations.go index 13da279d8..b34f05ed9 100644 --- a/pkg/apis/velero/v1/labels_annotations.go +++ b/pkg/apis/velero/v1/labels_annotations.go @@ -166,6 +166,14 @@ const ( // Velero checks this annotation to determine whether to skip resource excluding check. MustIncludeAdditionalItemAnnotation = "backup.velero.io/must-include-additional-items" + // MustIncludeAdditionalItemRestoreAnnotation is set by RestoreItemActions on the UpdatedItem + // to tell Velero to bypass global resource/namespace exclusion checks (and IncludeClusterResources=false) + // for that action's AdditionalItems. Value must be "true" to enable the bypass. The annotation is + // always stripped before the item is applied to the cluster when present, including non-"true" values. + // + // Notice: SkipRestore on the Execute output takes precedence. If SkipRestore is true, the + // annotation is never inspected and AdditionalItems are not processed. + MustIncludeAdditionalItemRestoreAnnotation = "restore.velero.io/must-include-additional-items" // SkippedNoCSIPVAnnotation - Velero checks this annotation on processed PVC to // find out if the snapshot was skipped b/c the PV is not provisioned via CSI SkippedNoCSIPVAnnotation = "backup.velero.io/skipped-no-csi-pv" diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index bc452b49c..7ba9ae6fd 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -1060,7 +1060,7 @@ func (ctx *restoreContext) processSelectedResource( continue } - w, e, _ := ctx.restoreItem(obj, groupResource, targetNS) + w, e, _ := ctx.restoreItem(obj, groupResource, targetNS, false) warnings.Merge(&w) errs.Merge(&e) processedItems++ @@ -1386,7 +1386,7 @@ func (ctx *restoreContext) getResource(groupResource schema.GroupResource, obj * return u, nil } -func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupResource schema.GroupResource, namespace string) (results.Result, results.Result, bool) { +func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupResource schema.GroupResource, namespace string, mustInclude bool) (results.Result, results.Result, bool) { warnings, errs := results.Result{}, results.Result{} // itemExists bool is used to determine whether to include this item in the "wait for additional items" list itemExists := false @@ -1403,27 +1403,41 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso // Check if group/resource should be restored. We need to do this here since // this method may be getting called for an additional item which is a group/resource // that's excluded. - if !ctx.resourceIncludesExcludes.ShouldInclude(groupResource.String()) && !ctx.resourceMustHave.Has(groupResource.String()) { - restoreLogger.Info("Not restoring item because resource is excluded") - return warnings, errs, itemExists - } - - // Check if namespace/cluster-scoped resource should be restored. We need - // to do this here since this method may be getting called for an additional - // item which is in a namespace that's excluded, or which is cluster-scoped - // and should be excluded. Note that we're checking the object's namespace ( - // via obj.GetNamespace()) instead of the namespace parameter, because we want - // to check the *original* namespace, not the remapped one if it's been remapped. // // Note: Additional items intentionally bypass fine-grained resource filter policies // (like per-namespace label/name selectors) to avoid breaking semantic dependencies, - // but they must still pass the global exclusions enforced below. - if namespace != "" { - if !ctx.namespaceIncludesExcludes.ShouldInclude(obj.GetNamespace()) && !ctx.resourceMustHave.Has(groupResource.String()) { - restoreLogger.Info("Not restoring item because namespace is excluded") + // but they must still pass the global exclusions enforced below unless mustInclude is set. + if mustInclude { + restoreLogger.Info("Skipping the resource/namespace exclusion checks because the item is marked as must-include") + } else { + if !ctx.resourceIncludesExcludes.ShouldInclude(groupResource.String()) && !ctx.resourceMustHave.Has(groupResource.String()) { + restoreLogger.Info("Not restoring item because resource is excluded") return warnings, errs, itemExists } + // Check if namespace/cluster-scoped resource should be restored. We need + // to do this here since this method may be getting called for an additional + // item which is in a namespace that's excluded, or which is cluster-scoped + // and should be excluded. Note that we're checking the object's namespace ( + // via obj.GetNamespace()) instead of the namespace parameter, because we want + // to check the *original* namespace, not the remapped one if it's been remapped. + if namespace != "" { + if !ctx.namespaceIncludesExcludes.ShouldInclude(obj.GetNamespace()) && !ctx.resourceMustHave.Has(groupResource.String()) { + restoreLogger.Info("Not restoring item because namespace is excluded") + return warnings, errs, itemExists + } + } else { + if boolptr.IsSetToFalse(ctx.restore.Spec.IncludeClusterResources) { + restoreLogger.Info("Not restoring item because it's cluster-scoped") + return warnings, errs, itemExists + } + } + } + + // Namespace creation runs unconditionally when namespace != "", regardless of + // mustInclude. This ensures target namespaces exist for additional items that + // bypass the namespace-exclusion check above. + if namespace != "" { // If the namespace scoped resource should be restored, ensure that the // namespace into which the resource is being restored into exists. // This is the *remapped* namespace that we are ensuring exists. @@ -1442,11 +1456,6 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso } ctx.restoredItems[itemKey] = restoredItemStatus{action: ItemRestoreResultCreated, itemExists: true, createdName: nsToEnsure.Name} } - } else { - if boolptr.IsSetToFalse(ctx.restore.Spec.IncludeClusterResources) { - restoreLogger.Info("Not restoring item because it's cluster-scoped") - return warnings, errs, itemExists - } } // Make a copy of object retrieved from backup to make it available unchanged @@ -1668,6 +1677,21 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso obj = unstructuredObj + mustIncludeAdditionalItems := false + if annotations := obj.GetAnnotations(); annotations != nil { + if _, present := annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation]; present { + // Only the string value "true" enables the bypass. + if annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] == "true" { + mustIncludeAdditionalItems = true + restoreLogger.Info("RestoreItemAction marked additional items as must-include; bypassing resource/namespace exclusion checks for them") + } + // Always strip the annotation so it never lands on the cluster, + // regardless of whether the value enabled the bypass. + delete(annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation) + obj.SetAnnotations(annotations) + } + } + var filteredAdditionalItems []velero.ResourceIdentifier for _, additionalItem := range executeOutput.AdditionalItems { itemPath := archive.GetItemFilePath(ctx.restoreDir, additionalItem.GroupResource.String(), additionalItem.Namespace, additionalItem.Name) @@ -1687,6 +1711,7 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso additionalObj, err := archive.Unmarshal(ctx.fileSystem, itemPath) if err != nil { errs.Add(namespace, errors.Wrapf(err, "error restoring additional item %s", additionalResourceID)) + continue } additionalItemNamespace := additionalItem.Namespace @@ -1696,7 +1721,7 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso } } - w, e, additionalItemExists := ctx.restoreItem(additionalObj, additionalItem.GroupResource, additionalItemNamespace) + w, e, additionalItemExists := ctx.restoreItem(additionalObj, additionalItem.GroupResource, additionalItemNamespace, mustIncludeAdditionalItems) if additionalItemExists { filteredAdditionalItems = append(filteredAdditionalItems, additionalItem) } diff --git a/pkg/restore/restore_test.go b/pkg/restore/restore_test.go index fc4051387..9d46c3e53 100644 --- a/pkg/restore/restore_test.go +++ b/pkg/restore/restore_test.go @@ -2150,6 +2150,102 @@ func TestRestoreActionAdditionalItems(t *testing.T) { test.PVs(): nil, }, }, + { + name: "must-include annotation bypasses resource exclusion for additional items", + restore: defaultRestore().IncludedResources("pods").Result(), + backup: defaultBackup().Result(), + tarball: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + Done(), + apiResources: []*test.APIResource{test.Pods(), test.PVs()}, + actions: []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + }, + want: map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): {"/pv-1"}, + }, + }, + { + name: "must-include annotation bypasses namespace exclusion for additional items", + restore: defaultRestore().IncludedNamespaces("ns-1").Result(), + backup: defaultBackup().Result(), + tarball: test.NewTarWriter(t).AddItems("pods", builder.ForPod("ns-1", "pod-1").Result(), builder.ForPod("ns-2", "pod-2").Result()).Done(), + apiResources: []*test.APIResource{test.Pods()}, + actions: []riav2.RestoreItemAction{ + &pluggableAction{ + selector: velero.ResourceSelector{IncludedNamespaces: []string{"ns-1"}}, + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.Pods, Namespace: "ns-2", Name: "pod-2"}, + }, + }, nil + }, + }, + }, + want: map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1", "ns-2/pod-2"}, + }, + }, + { + name: "must-include annotation bypasses IncludeClusterResources=false for additional items", + restore: defaultRestore().IncludeClusterResources(false).Result(), + backup: defaultBackup().Result(), + tarball: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + Done(), + apiResources: []*test.APIResource{test.Pods(), test.PVs()}, + actions: []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + }, + want: map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): {"/pv-1"}, + }, + }, } for _, tc := range tests { @@ -2180,6 +2276,324 @@ func TestRestoreActionAdditionalItems(t *testing.T) { } } +// TestRestoreMustIncludeAdditionalItems covers restore must-include edge cases beyond the +// basic filter-bypass cases in TestRestoreActionAdditionalItems. +func TestRestoreMustIncludeAdditionalItems(t *testing.T) { + t.Run("must-include annotation is stripped from the restored item", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + annotations["keep-me"] = "yes" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + + got, err := h.DynamicClient.Resource(test.Pods().GVR()).Namespace("ns-1").Get(t.Context(), "pod-1", metav1.GetOptions{}) + require.NoError(t, err) + annotations := got.GetAnnotations() + assert.NotContains(t, annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation) + assert.Equal(t, "yes", annotations["keep-me"]) + }) + + t.Run("non-true must-include annotation is stripped without bypassing filters", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + h.AddItems(t, test.PVs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().IncludedResources("pods").Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "True" + annotations["keep-me"] = "yes" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + assertAPIContents(t, h, map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): nil, + }) + + got, err := h.DynamicClient.Resource(test.Pods().GVR()).Namespace("ns-1").Get(t.Context(), "pod-1", metav1.GetOptions{}) + require.NoError(t, err) + annotations := got.GetAnnotations() + assert.NotContains(t, annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation) + assert.Equal(t, "yes", annotations["keep-me"]) + }) + + t.Run("SkipRestore supersedes must-include annotation and skips additional items", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + h.AddItems(t, test.PVs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().IncludedResources("pods").Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + SkipRestore: true, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + assertAPIContents(t, h, map[*test.APIResource][]string{ + test.Pods(): nil, + test.PVs(): nil, + }) + }) + + t.Run("must-include does not restore additional items missing from the backup tarball", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + h.AddItems(t, test.PVs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().IncludedResources("pods").Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-missing"}, + }, + }, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, errs) + assertNonEmptyResults(t, "warning", warnings) + assertAPIContents(t, h, map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): nil, + }) + }) + + t.Run("transitive must-include requires each RIA level to re-set the annotation", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + h.AddItems(t, test.PVs()) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().IncludedResources("pods").Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-2", "pvc-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + // Parent pod RIA force-includes the excluded PV. + &pluggableAction{ + selector: velero.ResourceSelector{IncludedResources: []string{"pods"}}, + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + // Child PV RIA also re-sets the annotation to force-include an excluded PVC. + &pluggableAction{ + selector: velero.ResourceSelector{IncludedResources: []string{"persistentvolumes"}}, + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumeClaims, Namespace: "ns-2", Name: "pvc-1"}, + }, + }, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + assertAPIContents(t, h, map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): {"/pv-1"}, + test.PVCs(): {"ns-2/pvc-1"}, + }) + }) + + t.Run("without re-annotating, transitive additional items still respect filters", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.Pods()) + h.AddItems(t, test.PVs()) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().IncludedResources("pods").Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()). + AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-2", "pvc-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + selector: velero.ResourceSelector{IncludedResources: []string{"pods"}}, + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"}, + }, + }, nil + }, + }, + // Child PV RIA returns an additional PVC but does NOT set must-include. + &pluggableAction{ + selector: velero.ResourceSelector{IncludedResources: []string{"persistentvolumes"}}, + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: input.Item, + AdditionalItems: []velero.ResourceIdentifier{ + {GroupResource: kuberesource.PersistentVolumeClaims, Namespace: "ns-2", Name: "pvc-1"}, + }, + }, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + assertAPIContents(t, h, map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-1"}, + test.PVs(): {"/pv-1"}, + test.PVCs(): nil, + }) + }) +} + // TestShouldRestore runs the ShouldRestore function for various permutations of // existing/nonexisting/being-deleted PVs, PVCs, and namespaces, and verifies the // result/error matches expectations. diff --git a/site/content/docs/main/custom-plugins.md b/site/content/docs/main/custom-plugins.md index b0881d579..106ebfd0f 100644 --- a/site/content/docs/main/custom-plugins.md +++ b/site/content/docs/main/custom-plugins.md @@ -65,6 +65,32 @@ order in which item action plugins are invoked. However, if a single binary impl they may be invoked in the order in which they are registered but it is best to not depend on this implementation. This is not guaranteed officially and the implementation can change at any time. +### Must-include additional items (Restore Item Actions) + +Restore Item Actions may return `AdditionalItems` that Velero restores as dependencies of the current item. +By default those additional items must still pass the restore's global resource and namespace include/exclude +filters (and `IncludeClusterResources=false` for cluster-scoped resources). + +To force-restore hard dependencies despite those filters, set the following annotation on the `UpdatedItem` +returned from `Execute()`: + +``` +restore.velero.io/must-include-additional-items: "true" +``` + +Behavior: +- Only the string value `"true"` enables the bypass. +- The annotation applies blanket to all `AdditionalItems` from that RIA invocation (not per-item). +- Velero strips the annotation before applying the item to the cluster. +- `SkipRestore: true` takes precedence: if set, the annotation is never inspected and `AdditionalItems` are not processed. +- Must-include only bypasses filters; the additional item must still exist in the backup tarball. +- When an additional item targets an excluded namespace, Velero may still create that target namespace so the item can be restored. +- Cluster-scoped additional items are restored even when `IncludeClusterResources=false`. +- Transitive force-include requires each RIA level to re-set the annotation on its own `UpdatedItem`. + +This mirrors the backup-side annotation `backup.velero.io/must-include-additional-items` used by Backup Item Actions. +Installing an RIA that sets this annotation is a trust decision: the plugin can restore resources outside the operator's restore filters. + ## Plugin Logging Velero provides a [logger][2] that can be used by plugins to log structured information to the main Velero server log or