From 31333f7610f492239f92a021f8ccac7e6099da42 Mon Sep 17 00:00:00 2001
From: Prasad Joshi
Date: Fri, 4 Sep 2026 00:06:51 +0530
Subject: [PATCH] Add structured JSON output for velero restore describe
command (#9983)
* Add structured JSON output for velero restore describe command
Signed-off-by: Prasad Joshi
* Add changelog for PR 9983
Signed-off-by: Prasad Joshi
* Fix CSI snapshot restore JSON output to distinguish snapshot vs dataMovement type
Signed-off-by: Prasad Joshi
* Remove the redundant details wrapper key from podVolumeRestores so phase counts sit flat alongside uploaderType, matching the plaintext output structure.
Signed-off-by: Prasad Joshi
* Add missing resourcePolicy to json struct
Signed-off-by: Prasad Joshi
* Fix linter issue
Signed-off-by: Prasad Joshi
* fix codecoverage
Signed-off-by: Prasad Joshi
* Handle nil CSI snapshot fields in restore JSON describe
Signed-off-by: Prasad Joshi
* Fix lint issue
Signed-off-by: Prasad Joshi
---------
Signed-off-by: Prasad Joshi
Co-authored-by: lyndon-li <98304688+Lyndon-Li@users.noreply.github.com>
Co-authored-by: Tiger Kaovilai
---
changelogs/unreleased/9983-prajoshi | 1 +
pkg/cmd/cli/backup/describe.go | 4 +-
pkg/cmd/cli/restore/describe.go | 21 +-
.../output/restore_structured_describer.go | 542 ++++++++++
.../restore_structured_describer_test.go | 962 ++++++++++++++++++
5 files changed, 1524 insertions(+), 6 deletions(-)
create mode 100644 changelogs/unreleased/9983-prajoshi
create mode 100644 pkg/cmd/util/output/restore_structured_describer.go
create mode 100644 pkg/cmd/util/output/restore_structured_describer_test.go
diff --git a/changelogs/unreleased/9983-prajoshi b/changelogs/unreleased/9983-prajoshi
new file mode 100644
index 000000000..b496f4d6b
--- /dev/null
+++ b/changelogs/unreleased/9983-prajoshi
@@ -0,0 +1 @@
+Add structured JSON output support for velero restore describe command
diff --git a/pkg/cmd/cli/backup/describe.go b/pkg/cmd/cli/backup/describe.go
index dd819edd1..5235f9f9c 100644
--- a/pkg/cmd/cli/backup/describe.go
+++ b/pkg/cmd/cli/backup/describe.go
@@ -56,7 +56,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
cmd.CheckError(err)
if outputFormat != "plaintext" && outputFormat != "json" {
- cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid value are 'plaintext, json'", outputFormat))
+ cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid values are 'plaintext' and 'json'", outputFormat))
}
backups := new(velerov1api.BackupList)
@@ -118,7 +118,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
c.Flags().BoolVar(&details, "details", details, "Display additional detail in the command output.")
c.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", insecureSkipTLSVerify, "If true, the object store's TLS certificate will not be checked for validity. This is insecure and susceptible to man-in-the-middle attacks. Not recommended for production.")
c.Flags().StringVar(&caCertFile, "cacert", caCertFile, "Path to a certificate bundle to use when verifying TLS connections. If not specified, the CA certificate from the BackupStorageLocation will be used if available.")
- c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext, json'. 'json' only applies to a single backup")
+ c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext' and 'json'. 'json' only applies to a single backup")
return c
}
diff --git a/pkg/cmd/cli/restore/describe.go b/pkg/cmd/cli/restore/describe.go
index 7fc58ce22..a6cc4c59c 100644
--- a/pkg/cmd/cli/restore/describe.go
+++ b/pkg/cmd/cli/restore/describe.go
@@ -39,6 +39,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
listOptions metav1.ListOptions
details bool
insecureSkipTLSVerify bool
+ outputFormat = "plaintext"
)
config, err := client.LoadConfig()
@@ -54,6 +55,10 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
kbClient, err := f.KubebuilderClient()
cmd.CheckError(err)
+ if outputFormat != "plaintext" && outputFormat != "json" {
+ cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid values are 'plaintext' and 'json'", outputFormat))
+ }
+
restoreList := new(velerov1api.RestoreList)
if len(args) > 0 {
for _, name := range args {
@@ -81,12 +86,19 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
fmt.Fprintf(os.Stderr, "error getting PodVolumeRestores for restore %s: %v\n", restore.Name, err)
}
- s := output.DescribeRestore(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile)
- if first {
- first = false
+ // structured output only applies to a single restore in case of OOM
+ // To describe a list of restores in structured format, iterate and describe one at a time.
+ if len(restoreList.Items) == 1 && outputFormat != "plaintext" {
+ s := output.DescribeRestoreInSF(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile, outputFormat)
fmt.Print(s)
} else {
- fmt.Printf("\n\n%s", s)
+ s := output.DescribeRestore(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile)
+ if first {
+ first = false
+ fmt.Print(s)
+ } else {
+ fmt.Printf("\n\n%s", s)
+ }
}
}
cmd.CheckError(err)
@@ -98,6 +110,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command {
c.Flags().BoolVar(&details, "details", details, "Display additional detail in the command output.")
c.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", insecureSkipTLSVerify, "If true, the object store's TLS certificate will not be checked for validity. This is insecure and susceptible to man-in-the-middle attacks. Not recommended for production.")
c.Flags().StringVar(&caCertFile, "cacert", caCertFile, "Path to a certificate bundle to use when verifying TLS connections.")
+ c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext' and 'json'. 'json' only applies to a single restore")
return c
}
diff --git a/pkg/cmd/util/output/restore_structured_describer.go b/pkg/cmd/util/output/restore_structured_describer.go
new file mode 100644
index 000000000..106a43541
--- /dev/null
+++ b/pkg/cmd/util/output/restore_structured_describer.go
@@ -0,0 +1,542 @@
+/*
+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 output
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ corev1api "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kbclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/vmware-tanzu/velero/internal/volume"
+ velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
+ "github.com/vmware-tanzu/velero/pkg/cmd/util/cacert"
+ "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest"
+ "github.com/vmware-tanzu/velero/pkg/itemoperation"
+ "github.com/vmware-tanzu/velero/pkg/util/boolptr"
+ "github.com/vmware-tanzu/velero/pkg/util/results"
+)
+
+// DescribeRestoreInSF describes a restore in structured format.
+func DescribeRestoreInSF(
+ ctx context.Context,
+ kbClient kbclient.Client,
+ restore *velerov1api.Restore,
+ podVolumeRestores []velerov1api.PodVolumeRestore,
+ details bool,
+ insecureSkipTLSVerify bool,
+ caCertFile string,
+ outputFormat string,
+) string {
+ return DescribeInSF(func(d *StructuredDescriber) {
+ d.DescribeMetadata(restore.ObjectMeta)
+
+ phase := restore.Status.Phase
+ if phase == "" {
+ phase = velerov1api.RestorePhaseNew
+ }
+ phaseString := string(phase)
+ if !restore.DeletionTimestamp.IsZero() {
+ phaseString += " (Deleting)"
+ }
+ d.Describe("phase", phaseString)
+
+ describeRestoreProgressInSF(d, restore)
+ describeRestoreTimestampsInSF(d, restore)
+
+ if len(restore.Status.ValidationErrors) > 0 {
+ d.Describe("validationErrors", restore.Status.ValidationErrors)
+ }
+
+ describeRestoreResultsInSF(ctx, kbClient, d, restore, insecureSkipTLSVerify, caCertFile)
+
+ describeRestoreSpecInSF(d, restore.Spec)
+
+ describePodVolumeRestoresInSF(d, podVolumeRestores, details)
+
+ describeRestoreCSISnapshotsInSF(ctx, kbClient, d, restore, details, insecureSkipTLSVerify, caCertFile)
+
+ describeRestoreItemOperationsInSF(ctx, kbClient, d, restore, details, insecureSkipTLSVerify, caCertFile)
+
+ if restore.Status.HookStatus != nil {
+ hookStatus := map[string]any{
+ "hooksAttempted": restore.Status.HookStatus.HooksAttempted,
+ "hooksFailed": restore.Status.HookStatus.HooksFailed,
+ }
+ d.Describe("hookStatus", hookStatus)
+ }
+
+ if details {
+ describeRestoreResourceListInSF(ctx, kbClient, d, restore, insecureSkipTLSVerify, caCertFile)
+ }
+ }, outputFormat)
+}
+
+func describeRestoreProgressInSF(d *StructuredDescriber, restore *velerov1api.Restore) {
+ if restore.Status.Progress == nil {
+ return
+ }
+ progress := map[string]any{}
+ if restore.Status.Phase == velerov1api.RestorePhaseInProgress {
+ progress["estimatedTotalItemsToBeRestored"] = restore.Status.Progress.TotalItems
+ progress["itemsRestoredSoFar"] = restore.Status.Progress.ItemsRestored
+ } else {
+ progress["totalItemsToBeRestored"] = restore.Status.Progress.TotalItems
+ progress["itemsRestored"] = restore.Status.Progress.ItemsRestored
+ }
+ d.Describe("progress", progress)
+}
+
+func describeRestoreTimestampsInSF(d *StructuredDescriber, restore *velerov1api.Restore) {
+ timestamps := map[string]any{}
+ if restore.Status.StartTimestamp == nil || restore.Status.StartTimestamp.IsZero() {
+ timestamps["started"] = ""
+ } else {
+ timestamps["started"] = restore.Status.StartTimestamp.String()
+ }
+ if restore.Status.CompletionTimestamp == nil || restore.Status.CompletionTimestamp.IsZero() {
+ timestamps["completed"] = ""
+ } else {
+ timestamps["completed"] = restore.Status.CompletionTimestamp.String()
+ }
+ d.Describe("timestamps", timestamps)
+}
+
+func describeRestoreSpecInSF(d *StructuredDescriber, spec velerov1api.RestoreSpec) {
+ specInfo := map[string]any{}
+
+ specInfo["backupName"] = spec.BackupName
+
+ // namespaces
+ namespaceInfo := map[string]any{}
+ var s string
+ if len(spec.IncludedNamespaces) == 0 || (len(spec.IncludedNamespaces) == 1 && spec.IncludedNamespaces[0] == "*") {
+ s = "all namespaces found in the backup"
+ } else {
+ s = strings.Join(spec.IncludedNamespaces, ", ")
+ }
+ namespaceInfo["included"] = s
+ if len(spec.ExcludedNamespaces) == 0 {
+ s = emptyDisplay
+ } else {
+ s = strings.Join(spec.ExcludedNamespaces, ", ")
+ }
+ namespaceInfo["excluded"] = s
+ specInfo["namespaces"] = namespaceInfo
+
+ // resources
+ resourcesInfo := map[string]string{}
+ if len(spec.IncludedResources) == 0 {
+ s = "*"
+ } else {
+ s = strings.Join(spec.IncludedResources, ", ")
+ }
+ resourcesInfo["included"] = s
+ if len(spec.ExcludedResources) == 0 {
+ s = emptyDisplay
+ } else {
+ s = strings.Join(spec.ExcludedResources, ", ")
+ }
+ resourcesInfo["excluded"] = s
+ resourcesInfo["clusterScoped"] = BoolPointerString(spec.IncludeClusterResources, "excluded", "included", "auto")
+ specInfo["resources"] = resourcesInfo
+
+ // namespace mappings
+ if len(spec.NamespaceMapping) > 0 {
+ specInfo["namespaceMappings"] = spec.NamespaceMapping
+ } else {
+ specInfo["namespaceMappings"] = emptyDisplay
+ }
+
+ // label selector
+ s = emptyDisplay
+ if spec.LabelSelector != nil {
+ s = metav1.FormatLabelSelector(spec.LabelSelector)
+ }
+ specInfo["labelSelector"] = s
+
+ // or label selectors
+ if len(spec.OrLabelSelectors) == 0 {
+ specInfo["orLabelSelectors"] = emptyDisplay
+ } else {
+ orSelectors := make([]string, 0, len(spec.OrLabelSelectors))
+ for _, v := range spec.OrLabelSelectors {
+ orSelectors = append(orSelectors, metav1.FormatLabelSelector(v))
+ }
+ specInfo["orLabelSelectors"] = strings.Join(orSelectors, " or ")
+ }
+
+ specInfo["restorePVs"] = BoolPointerString(spec.RestorePVs, "false", "true", "auto")
+
+ // existing resource policy
+ if spec.ExistingResourcePolicy != "" {
+ specInfo["existingResourcePolicy"] = string(spec.ExistingResourcePolicy)
+ } else {
+ specInfo["existingResourcePolicy"] = emptyDisplay
+ }
+
+ specInfo["itemOperationTimeout"] = spec.ItemOperationTimeout.Duration.String()
+ specInfo["preserveNodePorts"] = BoolPointerString(spec.PreserveNodePorts, "false", "true", "auto")
+
+ // resource modifier
+ if spec.ResourceModifier != nil {
+ specInfo["resourceModifier"] = describeResourceModifierInSF(spec.ResourceModifier)
+ }
+
+ // resource policy
+ if spec.ResourcePolicy != nil {
+ specInfo["resourcePolicy"] = map[string]any{
+ "type": spec.ResourcePolicy.Kind,
+ "name": spec.ResourcePolicy.Name,
+ }
+ }
+
+ // uploader config
+ if spec.UploaderConfig != nil {
+ uploaderConfig := map[string]any{}
+ if boolptr.IsSetToTrue(spec.UploaderConfig.WriteSparseFiles) {
+ uploaderConfig["writeSparseFiles"] = true
+ }
+ if spec.UploaderConfig.ParallelFilesDownload > 0 {
+ uploaderConfig["parallelFilesDownload"] = spec.UploaderConfig.ParallelFilesDownload
+ }
+ specInfo["uploaderConfig"] = uploaderConfig
+ }
+
+ d.Describe("spec", specInfo)
+}
+
+func describeResourceModifierInSF(resModifier *corev1api.TypedLocalObjectReference) map[string]any {
+ return map[string]any{
+ "type": resModifier.Kind,
+ "name": resModifier.Name,
+ }
+}
+
+func describePodVolumeRestoresInSF(d *StructuredDescriber, restores []velerov1api.PodVolumeRestore, details bool) {
+ if len(restores) == 0 {
+ d.Describe("podVolumeRestores", "")
+ return
+ }
+
+ uploaderType := restores[0].Spec.UploaderType
+ podVolumeInfo := map[string]any{
+ "uploaderType": uploaderType,
+ }
+
+ restoresByPhase := groupRestoresByPhase(restores)
+
+ for _, phase := range []string{
+ string(velerov1api.PodVolumeRestorePhaseCompleted),
+ string(velerov1api.PodVolumeRestorePhaseCanceled),
+ string(velerov1api.PodVolumeRestorePhaseFailed),
+ "In Progress",
+ string(velerov1api.PodVolumeRestorePhasePrepared),
+ string(velerov1api.PodVolumeRestorePhaseAccepted),
+ string(velerov1api.PodVolumeRestorePhaseNew),
+ } {
+ if len(restoresByPhase[phase]) == 0 {
+ continue
+ }
+ if !details {
+ podVolumeInfo[phase] = len(restoresByPhase[phase])
+ continue
+ }
+
+ restoresByPod := new(volumesByPod)
+ for _, restore := range restoresByPhase[phase] {
+ restoresByPod.Add(restore.Spec.Pod.Namespace, restore.Spec.Pod.Name, restore.Spec.Volume, phase, restore.Status.Progress, nil)
+ }
+
+ podEntries := make([]map[string]string, 0)
+ for _, restoreGroup := range restoresByPod.Sorted() {
+ podEntries = append(podEntries, map[string]string{
+ restoreGroup.label: strings.Join(restoreGroup.volumes, ", "),
+ })
+ }
+ podVolumeInfo[phase] = podEntries
+ }
+
+ d.Describe("podVolumeRestores", podVolumeInfo)
+}
+
+func describeRestoreCSISnapshotsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, details bool, insecureSkipTLSVerify bool, caCertFile string) {
+ bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore)
+ if err != nil {
+ bslCACert = ""
+ }
+
+ buf := new(bytes.Buffer)
+ if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreVolumeInfo,
+ buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertFile, bslCACert); err != nil {
+ if !errors.Is(err, downloadrequest.ErrNotFound) {
+ d.Describe("csiSnapshotRestores", fmt.Sprintf("", err))
+ }
+ return
+ }
+
+ describeCSISnapshotsRestoresFromReader(d, buf, details)
+}
+
+func describeCSISnapshotsRestoresFromReader(d *StructuredDescriber, r io.Reader, details bool) {
+ var restoreVolInfo []volume.RestoreVolumeInfo
+ if err := json.NewDecoder(r).Decode(&restoreVolInfo); err != nil {
+ d.Describe("csiSnapshotRestores", fmt.Sprintf("", err))
+ return
+ }
+ describeCSISnapshotsRestoresInSF(d, restoreVolInfo, details)
+}
+
+func describeCSISnapshotsRestoresInSF(d *StructuredDescriber, restoreVolInfo []volume.RestoreVolumeInfo, details bool) {
+ var nonDMInfoList, dmInfoList []volume.RestoreVolumeInfo
+ for _, info := range restoreVolInfo {
+ if info.RestoreMethod != volume.CSISnapshot {
+ continue
+ }
+ if info.SnapshotDataMoved {
+ dmInfoList = append(dmInfoList, info)
+ } else {
+ nonDMInfoList = append(nonDMInfoList, info)
+ }
+ }
+
+ if len(nonDMInfoList) == 0 && len(dmInfoList) == 0 {
+ d.Describe("csiSnapshotRestores", "")
+ return
+ }
+
+ csiRestores := map[string]any{}
+
+ for _, info := range nonDMInfoList {
+ key := fmt.Sprintf("%s/%s", info.PVCNamespace, info.PVCName)
+ if details {
+ if info.CSISnapshotInfo == nil {
+ csiRestores[key] = map[string]any{
+ "snapshot": "",
+ }
+ continue
+ }
+ csiRestores[key] = map[string]any{
+ "snapshot": map[string]any{
+ "snapshotContentName": info.CSISnapshotInfo.VSCName,
+ "storageSnapshotID": info.CSISnapshotInfo.SnapshotHandle,
+ "csiDriver": info.CSISnapshotInfo.Driver,
+ },
+ }
+ } else {
+ csiRestores[key] = map[string]any{
+ "snapshot": "specify --details for more information",
+ }
+ }
+ }
+
+ for _, info := range dmInfoList {
+ key := fmt.Sprintf("%s/%s", info.PVCNamespace, info.PVCName)
+ if details {
+ if info.SnapshotDataMovementInfo == nil {
+ csiRestores[key] = map[string]any{
+ "dataMovement": "",
+ }
+ continue
+ }
+ csiRestores[key] = map[string]any{
+ "dataMovement": map[string]any{
+ "operationID": info.SnapshotDataMovementInfo.OperationID,
+ "dataMover": info.SnapshotDataMovementInfo.DataMover,
+ "uploaderType": info.SnapshotDataMovementInfo.UploaderType,
+ },
+ }
+ } else {
+ csiRestores[key] = map[string]any{
+ "dataMovement": "specify --details for more information",
+ }
+ }
+ }
+
+ d.Describe("csiSnapshotRestores", csiRestores)
+}
+
+func describeRestoreResultsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, insecureSkipTLSVerify bool, caCertPath string) {
+ if restore.Status.Warnings == 0 && restore.Status.Errors == 0 {
+ return
+ }
+
+ bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore)
+ if err != nil {
+ bslCACert = ""
+ }
+
+ var buf bytes.Buffer
+
+ warnings, errs := make(map[string]any), make(map[string]any)
+ defer func() {
+ if restore.Status.Warnings > 0 {
+ d.Describe("warnings", warnings)
+ }
+ if restore.Status.Errors > 0 {
+ d.Describe("errors", errs)
+ }
+ }()
+
+ if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreResults, &buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil {
+ if restore.Status.Warnings > 0 {
+ warnings["errorGettingWarnings"] = fmt.Sprintf("", err)
+ }
+ if restore.Status.Errors > 0 {
+ errs["errorGettingErrors"] = fmt.Sprintf("", err)
+ }
+ return
+ }
+
+ describeRestoreResultsFromReader(warnings, errs, &buf, restore)
+}
+
+func describeRestoreResultsFromReader(warnings, errs map[string]any, r io.Reader, restore *velerov1api.Restore) {
+ var resultMap map[string]results.Result
+ if err := json.NewDecoder(r).Decode(&resultMap); err != nil {
+ if restore.Status.Warnings > 0 {
+ warnings["errorDecodingWarnings"] = fmt.Sprintf("", err)
+ }
+ if restore.Status.Errors > 0 {
+ errs["errorDecodingErrors"] = fmt.Sprintf("", err)
+ }
+ return
+ }
+
+ if restore.Status.Warnings > 0 {
+ describeResultInSF(warnings, resultMap["warnings"])
+ }
+ if restore.Status.Errors > 0 {
+ describeResultInSF(errs, resultMap["errors"])
+ }
+}
+
+func describeRestoreItemOperationsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, details bool, insecureSkipTLSVerify bool, caCertPath string) {
+ status := restore.Status
+ if status.RestoreItemOperationsAttempted == 0 {
+ return
+ }
+
+ opsInfo := map[string]any{
+ "attempted": status.RestoreItemOperationsAttempted,
+ "completed": status.RestoreItemOperationsCompleted,
+ "failed": status.RestoreItemOperationsFailed,
+ }
+
+ if !details {
+ d.Describe("restoreItemOperations", opsInfo)
+ return
+ }
+
+ bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore)
+ if err != nil {
+ bslCACert = ""
+ }
+
+ buf := new(bytes.Buffer)
+ if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreItemOperations, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil {
+ opsInfo["errorGettingOperations"] = fmt.Sprintf("", err)
+ d.Describe("restoreItemOperations", opsInfo)
+ return
+ }
+
+ describeRestoreItemOperationsFromReader(d, opsInfo, buf)
+}
+
+func describeRestoreItemOperationsFromReader(d *StructuredDescriber, opsInfo map[string]any, r io.Reader) {
+ var operations []*itemoperation.RestoreOperation
+ if err := json.NewDecoder(r).Decode(&operations); err != nil {
+ opsInfo["errorReadingOperations"] = fmt.Sprintf("", err)
+ d.Describe("restoreItemOperations", opsInfo)
+ return
+ }
+
+ opsList := make([]map[string]any, 0, len(operations))
+ for _, op := range operations {
+ opsList = append(opsList, describeRestoreItemOperationInSF(op))
+ }
+ opsInfo["operations"] = opsList
+ d.Describe("restoreItemOperations", opsInfo)
+}
+
+func describeRestoreItemOperationInSF(op *itemoperation.RestoreOperation) map[string]any {
+ opEntry := map[string]any{
+ "resource": fmt.Sprintf("%s %s/%s", op.Spec.ResourceIdentifier, op.Spec.ResourceIdentifier.Namespace, op.Spec.ResourceIdentifier.Name),
+ "restoreItemActionPlugin": op.Spec.RestoreItemAction,
+ "operationID": op.Spec.OperationID,
+ "phase": op.Status.Phase,
+ }
+ if op.Status.Error != "" {
+ opEntry["error"] = op.Status.Error
+ }
+ if op.Status.NTotal > 0 || op.Status.NCompleted > 0 {
+ opEntry["progress"] = map[string]any{
+ "completed": op.Status.NCompleted,
+ "total": op.Status.NTotal,
+ "units": op.Status.OperationUnits,
+ }
+ }
+ if op.Status.Description != "" {
+ opEntry["progressDescription"] = op.Status.Description
+ }
+ if op.Status.Created != nil {
+ opEntry["created"] = op.Status.Created.String()
+ }
+ if op.Status.Started != nil {
+ opEntry["started"] = op.Status.Started.String()
+ }
+ if op.Status.Updated != nil {
+ opEntry["updated"] = op.Status.Updated.String()
+ }
+ return opEntry
+}
+
+func describeRestoreResourceListInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, insecureSkipTLSVerify bool, caCertPath string) {
+ bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore)
+ if err != nil {
+ bslCACert = ""
+ }
+
+ buf := new(bytes.Buffer)
+ if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreResourceList, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil {
+ if errors.Is(err, downloadrequest.ErrNotFound) {
+ d.Describe("resourceList", "")
+ } else {
+ d.Describe("resourceList", fmt.Sprintf("", err))
+ }
+ return
+ }
+
+ describeRestoreResourceListFromReader(d, buf)
+}
+
+func describeRestoreResourceListFromReader(d *StructuredDescriber, r io.Reader) {
+ var resourceList map[string][]string
+ if err := json.NewDecoder(r).Decode(&resourceList); err != nil {
+ d.Describe("resourceList", fmt.Sprintf("", err))
+ return
+ }
+
+ d.Describe("resourceList", resourceList)
+}
diff --git a/pkg/cmd/util/output/restore_structured_describer_test.go b/pkg/cmd/util/output/restore_structured_describer_test.go
new file mode 100644
index 000000000..42fbb4d44
--- /dev/null
+++ b/pkg/cmd/util/output/restore_structured_describer_test.go
@@ -0,0 +1,962 @@
+/*
+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 output
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1api "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/vmware-tanzu/velero/internal/volume"
+ velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
+ "github.com/vmware-tanzu/velero/pkg/builder"
+ "github.com/vmware-tanzu/velero/pkg/itemoperation"
+ "github.com/vmware-tanzu/velero/pkg/test"
+ "github.com/vmware-tanzu/velero/pkg/util/boolptr"
+ "github.com/vmware-tanzu/velero/pkg/util/results"
+)
+
+func TestDescribeRestoreProgressInSF(t *testing.T) {
+ testcases := []struct {
+ name string
+ input *velerov1api.Restore
+ expect map[string]any
+ }{
+ {
+ name: "nil progress — nothing added",
+ input: builder.ForRestore("velero", "r1").Result(),
+ expect: map[string]any{},
+ },
+ {
+ name: "in-progress phase shows estimated labels",
+ input: func() *velerov1api.Restore {
+ r := builder.ForRestore("velero", "r2").Phase(velerov1api.RestorePhaseInProgress).Result()
+ r.Status.Progress = &velerov1api.RestoreProgress{TotalItems: 100, ItemsRestored: 50}
+ return r
+ }(),
+ expect: map[string]any{
+ "progress": map[string]any{
+ "estimatedTotalItemsToBeRestored": 100,
+ "itemsRestoredSoFar": 50,
+ },
+ },
+ },
+ {
+ name: "completed phase shows final labels",
+ input: func() *velerov1api.Restore {
+ r := builder.ForRestore("velero", "r3").Phase(velerov1api.RestorePhaseCompleted).Result()
+ r.Status.Progress = &velerov1api.RestoreProgress{TotalItems: 80, ItemsRestored: 80}
+ return r
+ }(),
+ expect: map[string]any{
+ "progress": map[string]any{
+ "totalItemsToBeRestored": 80,
+ "itemsRestored": 80,
+ },
+ },
+ },
+ }
+ for _, tc := range testcases {
+ t.Run(tc.name, func(tt *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describeRestoreProgressInSF(sd, tc.input)
+ assert.Equal(tt, tc.expect, sd.output)
+ })
+ }
+}
+
+func TestDescribeRestoreTimestampsInSF(t *testing.T) {
+ t1 := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
+ t2 := time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC)
+ mt1 := metav1.NewTime(t1)
+ mt2 := metav1.NewTime(t2)
+
+ testcases := []struct {
+ name string
+ input *velerov1api.Restore
+ expect map[string]any
+ }{
+ {
+ name: "nil timestamps show ",
+ input: builder.ForRestore("velero", "r1").Result(),
+ expect: map[string]any{
+ "timestamps": map[string]any{
+ "started": "",
+ "completed": "",
+ },
+ },
+ },
+ {
+ name: "both timestamps set",
+ input: func() *velerov1api.Restore {
+ r := builder.ForRestore("velero", "r2").Result()
+ r.Status.StartTimestamp = &mt1
+ r.Status.CompletionTimestamp = &mt2
+ return r
+ }(),
+ expect: map[string]any{
+ "timestamps": map[string]any{
+ "started": mt1.String(),
+ "completed": mt2.String(),
+ },
+ },
+ },
+ }
+ for _, tc := range testcases {
+ t.Run(tc.name, func(tt *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describeRestoreTimestampsInSF(sd, tc.input)
+ assert.Equal(tt, tc.expect, sd.output)
+ })
+ }
+}
+
+func TestDescribeRestoreSpecInSF(t *testing.T) {
+ testcases := []struct {
+ name string
+ spec velerov1api.RestoreSpec
+ expect map[string]any
+ }{
+ {
+ name: "minimal spec",
+ spec: velerov1api.RestoreSpec{
+ BackupName: "backup-1",
+ },
+ expect: map[string]any{
+ "spec": map[string]any{
+ "backupName": "backup-1",
+ "namespaces": map[string]any{
+ "included": "all namespaces found in the backup",
+ "excluded": emptyDisplay,
+ },
+ "resources": map[string]string{
+ "included": "*",
+ "excluded": emptyDisplay,
+ "clusterScoped": "auto",
+ },
+ "namespaceMappings": emptyDisplay,
+ "labelSelector": emptyDisplay,
+ "orLabelSelectors": emptyDisplay,
+ "restorePVs": "auto",
+ "existingResourcePolicy": emptyDisplay,
+ "itemOperationTimeout": "0s",
+ "preserveNodePorts": "auto",
+ },
+ },
+ },
+ {
+ name: "included namespaces wildcard treated as all",
+ spec: velerov1api.RestoreSpec{
+ BackupName: "backup-2",
+ IncludedNamespaces: []string{"*"},
+ ExcludedNamespaces: []string{"kube-system"},
+ IncludedResources: []string{"pods", "configmaps"},
+ ExcludedResources: []string{"secrets"},
+ ExistingResourcePolicy: velerov1api.ResourcePolicyTypeUpdate,
+ },
+ expect: map[string]any{
+ "spec": map[string]any{
+ "backupName": "backup-2",
+ "namespaces": map[string]any{
+ "included": "all namespaces found in the backup",
+ "excluded": "kube-system",
+ },
+ "resources": map[string]string{
+ "included": "pods, configmaps",
+ "excluded": "secrets",
+ "clusterScoped": "auto",
+ },
+ "namespaceMappings": emptyDisplay,
+ "labelSelector": emptyDisplay,
+ "orLabelSelectors": emptyDisplay,
+ "restorePVs": "auto",
+ "existingResourcePolicy": string(velerov1api.ResourcePolicyTypeUpdate),
+ "itemOperationTimeout": "0s",
+ "preserveNodePorts": "auto",
+ },
+ },
+ },
+ {
+ name: "spec with resource modifier and uploader config",
+ spec: velerov1api.RestoreSpec{
+ BackupName: "backup-3",
+ ResourceModifier: &corev1api.TypedLocalObjectReference{
+ Kind: "ConfigMap",
+ Name: "my-modifier",
+ },
+ UploaderConfig: &velerov1api.UploaderConfigForRestore{
+ WriteSparseFiles: boolptr.True(),
+ ParallelFilesDownload: 4,
+ },
+ },
+ expect: map[string]any{
+ "spec": map[string]any{
+ "backupName": "backup-3",
+ "namespaces": map[string]any{
+ "included": "all namespaces found in the backup",
+ "excluded": emptyDisplay,
+ },
+ "resources": map[string]string{
+ "included": "*",
+ "excluded": emptyDisplay,
+ "clusterScoped": "auto",
+ },
+ "namespaceMappings": emptyDisplay,
+ "labelSelector": emptyDisplay,
+ "orLabelSelectors": emptyDisplay,
+ "restorePVs": "auto",
+ "existingResourcePolicy": emptyDisplay,
+ "itemOperationTimeout": "0s",
+ "preserveNodePorts": "auto",
+ "resourceModifier": map[string]any{
+ "type": "ConfigMap",
+ "name": "my-modifier",
+ },
+ "uploaderConfig": map[string]any{
+ "writeSparseFiles": true,
+ "parallelFilesDownload": 4,
+ },
+ },
+ },
+ },
+ {
+ name: "namespaces, mappings, selectors, resource policy and flags",
+ spec: velerov1api.RestoreSpec{
+ BackupName: "backup-4",
+ IncludedNamespaces: []string{"ns-a", "ns-b"},
+ NamespaceMapping: map[string]string{"ns-a": "ns-a-new"},
+ LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}},
+ OrLabelSelectors: []*metav1.LabelSelector{{MatchLabels: map[string]string{"env": "prod"}}, {MatchLabels: map[string]string{"env": "stage"}}},
+ IncludeClusterResources: boolptr.True(),
+ RestorePVs: boolptr.True(),
+ PreserveNodePorts: boolptr.False(),
+ ResourcePolicy: &corev1api.TypedLocalObjectReference{
+ Kind: "configmap",
+ Name: "volume-policy",
+ },
+ UploaderConfig: &velerov1api.UploaderConfigForRestore{
+ WriteSparseFiles: boolptr.False(),
+ },
+ },
+ expect: map[string]any{
+ "spec": map[string]any{
+ "backupName": "backup-4",
+ "namespaces": map[string]any{
+ "included": "ns-a, ns-b",
+ "excluded": emptyDisplay,
+ },
+ "resources": map[string]string{
+ "included": "*",
+ "excluded": emptyDisplay,
+ "clusterScoped": "included",
+ },
+ "namespaceMappings": map[string]string{"ns-a": "ns-a-new"},
+ "labelSelector": "app=nginx",
+ "orLabelSelectors": "env=prod or env=stage",
+ "restorePVs": "true",
+ "existingResourcePolicy": emptyDisplay,
+ "itemOperationTimeout": "0s",
+ "preserveNodePorts": "false",
+ "resourcePolicy": map[string]any{
+ "type": "configmap",
+ "name": "volume-policy",
+ },
+ "uploaderConfig": map[string]any{},
+ },
+ },
+ },
+ }
+ for _, tc := range testcases {
+ t.Run(tc.name, func(tt *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describeRestoreSpecInSF(sd, tc.spec)
+ assert.Equal(tt, tc.expect, sd.output)
+ })
+ }
+}
+
+func TestDescribePodVolumeRestoresInSF(t *testing.T) {
+ pvr1 := builder.ForPodVolumeRestore("velero", "pvr-1").
+ UploaderType("kopia").
+ Phase(velerov1api.PodVolumeRestorePhaseCompleted).
+ Volume("vol-1").
+ PodName("pod-1").
+ PodNamespace("ns-1").Result()
+
+ pvr2 := builder.ForPodVolumeRestore("velero", "pvr-2").
+ UploaderType("kopia").
+ Phase(velerov1api.PodVolumeRestorePhaseCompleted).
+ Volume("vol-2").
+ PodName("pod-2").
+ PodNamespace("ns-1").Result()
+
+ pvr3 := builder.ForPodVolumeRestore("velero", "pvr-3").
+ UploaderType("kopia").
+ Phase(velerov1api.PodVolumeRestorePhaseFailed).
+ Volume("vol-3").
+ PodName("pod-3").
+ PodNamespace("ns-1").Result()
+
+ testcases := []struct {
+ name string
+ restores []velerov1api.PodVolumeRestore
+ details bool
+ expect map[string]any
+ }{
+ {
+ name: "empty list",
+ restores: []velerov1api.PodVolumeRestore{},
+ details: false,
+ expect: map[string]any{
+ "podVolumeRestores": "",
+ },
+ },
+ {
+ name: "2 completed, no details",
+ restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2},
+ details: false,
+ expect: map[string]any{
+ "podVolumeRestores": map[string]any{
+ "uploaderType": "kopia",
+ "Completed": 2,
+ },
+ },
+ },
+ {
+ name: "2 completed with details",
+ restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2},
+ details: true,
+ expect: map[string]any{
+ "podVolumeRestores": map[string]any{
+ "uploaderType": "kopia",
+ "Completed": []map[string]string{
+ {"ns-1/pod-1": "vol-1"},
+ {"ns-1/pod-2": "vol-2"},
+ },
+ },
+ },
+ },
+ {
+ name: "completed and failed, no details",
+ restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2, *pvr3},
+ details: false,
+ expect: map[string]any{
+ "podVolumeRestores": map[string]any{
+ "uploaderType": "kopia",
+ "Completed": 2,
+ "Failed": 1,
+ },
+ },
+ },
+ }
+ for _, tc := range testcases {
+ t.Run(tc.name, func(tt *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describePodVolumeRestoresInSF(sd, tc.restores, tc.details)
+ assert.Equal(tt, tc.expect, sd.output)
+ })
+ }
+}
+
+func TestDescribeRestoreCSISnapshotsInSF_NoData(t *testing.T) {
+ testcases := []struct {
+ name string
+ inputVolInfoList []volume.RestoreVolumeInfo
+ details bool
+ expect map[string]any
+ }{
+ {
+ name: "no CSI entries — none included",
+ inputVolInfoList: []volume.RestoreVolumeInfo{},
+ details: false,
+ expect: map[string]any{
+ "csiSnapshotRestores": "",
+ },
+ },
+ {
+ name: "only native snapshot entries — none included",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.NativeSnapshot,
+ PVCName: "pvc-1",
+ PVCNamespace: "ns-1",
+ },
+ },
+ details: false,
+ expect: map[string]any{
+ "csiSnapshotRestores": "",
+ },
+ },
+ {
+ name: "CSI snapshot, no details",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ PVCName: "pvc-1",
+ PVCNamespace: "ns-1",
+ CSISnapshotInfo: &volume.CSISnapshotInfo{
+ VSCName: "vsc-1",
+ SnapshotHandle: "snap-handle-1",
+ Driver: "csi.test.driver",
+ },
+ },
+ },
+ details: false,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-1/pvc-1": map[string]any{
+ "snapshot": "specify --details for more information",
+ },
+ },
+ },
+ },
+ {
+ name: "CSI snapshot, with details",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ PVCName: "pvc-2",
+ PVCNamespace: "ns-2",
+ CSISnapshotInfo: &volume.CSISnapshotInfo{
+ VSCName: "vsc-2",
+ SnapshotHandle: "snap-handle-2",
+ Driver: "csi.test.driver",
+ },
+ },
+ },
+ details: true,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-2/pvc-2": map[string]any{
+ "snapshot": map[string]any{
+ "snapshotContentName": "vsc-2",
+ "storageSnapshotID": "snap-handle-2",
+ "csiDriver": "csi.test.driver",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "data movement entry, with details",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ SnapshotDataMoved: true,
+ PVCName: "pvc-3",
+ PVCNamespace: "ns-3",
+ SnapshotDataMovementInfo: &volume.SnapshotDataMovementInfo{
+ OperationID: "op-3",
+ DataMover: "velero",
+ UploaderType: "kopia",
+ },
+ },
+ },
+ details: true,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-3/pvc-3": map[string]any{
+ "dataMovement": map[string]any{
+ "operationID": "op-3",
+ "dataMover": "velero",
+ "uploaderType": "kopia",
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "data movement entry, no details",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ SnapshotDataMoved: true,
+ PVCName: "pvc-3",
+ PVCNamespace: "ns-3",
+ SnapshotDataMovementInfo: &volume.SnapshotDataMovementInfo{
+ OperationID: "op-3",
+ DataMover: "velero",
+ UploaderType: "kopia",
+ },
+ },
+ },
+ details: false,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-3/pvc-3": map[string]any{
+ "dataMovement": "specify --details for more information",
+ },
+ },
+ },
+ },
+ {
+ name: "CSI snapshot with details and nil CSISnapshotInfo",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ PVCName: "pvc-4",
+ PVCNamespace: "ns-4",
+ CSISnapshotInfo: nil,
+ },
+ },
+ details: true,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-4/pvc-4": map[string]any{
+ "snapshot": "",
+ },
+ },
+ },
+ },
+ {
+ name: "data movement with details and nil SnapshotDataMovementInfo",
+ inputVolInfoList: []volume.RestoreVolumeInfo{
+ {
+ RestoreMethod: volume.CSISnapshot,
+ SnapshotDataMoved: true,
+ PVCName: "pvc-5",
+ PVCNamespace: "ns-5",
+ SnapshotDataMovementInfo: nil,
+ },
+ },
+ details: true,
+ expect: map[string]any{
+ "csiSnapshotRestores": map[string]any{
+ "ns-5/pvc-5": map[string]any{
+ "dataMovement": "",
+ },
+ },
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(tt *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describeCSISnapshotsRestoresInSF(sd, tc.inputVolInfoList, tc.details)
+ assert.Equal(tt, tc.expect, sd.output)
+ })
+ }
+}
+
+func TestDescribeCSISnapshotsRestoresFromReader(t *testing.T) {
+ t.Run("invalid json", func(t *testing.T) {
+ sd := &StructuredDescriber{output: make(map[string]any), format: ""}
+ describeCSISnapshotsRestoresFromReader(sd, strings.NewReader("not-json"), false)
+ got, ok := sd.output["csiSnapshotRestores"].(string)
+ require.True(t, ok)
+ assert.Contains(t, got, "