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, "