From e569d1135946564c52ed99475d79aeef39d9e582 Mon Sep 17 00:00:00 2001 From: allenxu404 Date: Thu, 16 Feb 2023 12:32:19 +0800 Subject: [PATCH] Add a json output to velero backup describe Signed-off-by: allenxu404 --- changelogs/unreleased/5865-allenxu404 | 1 + pkg/cmd/cli/backup/describe.go | 22 +- .../output/backup_structured_describer.go | 462 ++++++++++++++++++ pkg/cmd/util/output/describe.go | 57 +++ 4 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 changelogs/unreleased/5865-allenxu404 create mode 100644 pkg/cmd/util/output/backup_structured_describer.go diff --git a/changelogs/unreleased/5865-allenxu404 b/changelogs/unreleased/5865-allenxu404 new file mode 100644 index 0000000000..6e25fb7357 --- /dev/null +++ b/changelogs/unreleased/5865-allenxu404 @@ -0,0 +1 @@ +Add a json output to cmd velero backup describe \ No newline at end of file diff --git a/pkg/cmd/cli/backup/describe.go b/pkg/cmd/cli/backup/describe.go index 1284e29c93..e53656fd6b 100644 --- a/pkg/cmd/cli/backup/describe.go +++ b/pkg/cmd/cli/backup/describe.go @@ -41,6 +41,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { listOptions metav1.ListOptions details bool insecureSkipTLSVerify bool + outputFormat string ) config, err := client.LoadConfig() @@ -59,6 +60,10 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { kbClient, err := f.KubebuilderClient() cmd.CheckError(err) + if outputFormat != "" && outputFormat != "json" { + cmd.CheckError(fmt.Errorf("Invalid argument '%s' for '--o'. Valid formats is 'json'", outputFormat)) + } + var backups *velerov1api.BackupList if len(args) > 0 { backups = new(velerov1api.BackupList) @@ -102,13 +107,21 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { } } - s := output.DescribeBackup(context.Background(), kbClient, &backups.Items[i], deleteRequestList.Items, podVolumeBackupList.Items, vscList.Items, details, veleroClient, insecureSkipTLSVerify, caCertFile) - if first { - first = false + // `output` flag only applies to a single backup in case of OOM + // To describe the list of backups in structured format, users could iterate over the list and describe backup one after another. + if len(backups.Items) == 1 && outputFormat != "" { + s := output.DescribeBackupInSF(context.Background(), kbClient, &backups.Items[i], deleteRequestList.Items, podVolumeBackupList.Items, vscList.Items, details, veleroClient, insecureSkipTLSVerify, caCertFile, outputFormat) fmt.Print(s) } else { - fmt.Printf("\n\n%s", s) + s := output.DescribeBackup(context.Background(), kbClient, &backups.Items[i], deleteRequestList.Items, podVolumeBackupList.Items, vscList.Items, details, veleroClient, insecureSkipTLSVerify, caCertFile) + if first { + first = false + fmt.Print(s) + } else { + fmt.Printf("\n\n%s", s) + } } + } cmd.CheckError(err) }, @@ -118,5 +131,6 @@ 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().StringVar(&outputFormat, "o", outputFormat, "Output display format. Valid formats are 'json'. This flag only applies to a single backup") return c } diff --git a/pkg/cmd/util/output/backup_structured_describer.go b/pkg/cmd/util/output/backup_structured_describer.go new file mode 100644 index 0000000000..5b0994c0c8 --- /dev/null +++ b/pkg/cmd/util/output/backup_structured_describer.go @@ -0,0 +1,462 @@ +/* +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" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v4/apis/volumesnapshot/v1" + + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest" + "github.com/vmware-tanzu/velero/pkg/features" + clientset "github.com/vmware-tanzu/velero/pkg/generated/clientset/versioned" + "github.com/vmware-tanzu/velero/pkg/volume" +) + +// DescribeBackupInSF describes a backup in structured format. +func DescribeBackupInSF( + ctx context.Context, + kbClient kbclient.Client, + backup *velerov1api.Backup, + deleteRequests []velerov1api.DeleteBackupRequest, + podVolumeBackups []velerov1api.PodVolumeBackup, + volumeSnapshotContents []snapshotv1api.VolumeSnapshotContent, + details bool, + veleroClient clientset.Interface, + insecureSkipTLSVerify bool, + caCertFile string, + outputFormat string, +) string { + return DescribeInSF(func(d *StructuredDescriber) { + d.DescribeMetadata(backup.ObjectMeta) + + d.Describe("phase", backup.Status.Phase) + + status := backup.Status + if len(status.ValidationErrors) > 0 { + d.Describe("validationErrors", status.ValidationErrors) + } + + d.Describe("errors", status.Errors) + d.Describe("warnings", status.Warnings) + + DescribeBackupSpecInSF(d, backup.Spec) + + DescribeBackupStatusInSF(ctx, kbClient, d, backup, details, veleroClient, insecureSkipTLSVerify, caCertFile) + + if len(deleteRequests) > 0 { + DescribeDeleteBackupRequestsInSF(d, deleteRequests) + } + + if features.IsEnabled(velerov1api.CSIFeatureFlag) { + DescribeCSIVolumeSnapshotsInSF(d, details, volumeSnapshotContents) + } + + if len(podVolumeBackups) > 0 { + DescribePodVolumeBackupsInSF(d, podVolumeBackups, details) + } + }, outputFormat) +} + +// DescribeBackupSpecInSF describes a backup spec in structured format. +func DescribeBackupSpecInSF(d *StructuredDescriber, spec velerov1api.BackupSpec) { + backupSpecInfo := make(map[string]interface{}) + var s string + + // describe namespaces + namespaceInfo := make(map[string]interface{}) + if len(spec.IncludedNamespaces) == 0 { + s = "*" + } 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 + backupSpecInfo["namespaces"] = namespaceInfo + + // describe resources + resourcesInfo := make(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") + backupSpecInfo["resources"] = resourcesInfo + + // describe label selector + s = emptyDisplay + if spec.LabelSelector != nil { + s = metav1.FormatLabelSelector(spec.LabelSelector) + } + backupSpecInfo["labelSelector"] = s + + // describe storage location + backupSpecInfo["storageLocation"] = spec.StorageLocation + + // describe snapshot volumes + backupSpecInfo["veleroNativeSnapshotPVs"] = BoolPointerString(spec.SnapshotVolumes, "false", "true", "auto") + + // describe TTL + backupSpecInfo["TTL"] = spec.TTL.Duration.String() + + // describe CSI snapshot timeout + backupSpecInfo["CSISnapshotTimeout"] = spec.CSISnapshotTimeout.Duration.String() + + // describe hooks + hooksInfo := make(map[string]interface{}) + hooksResources := make(map[string]interface{}) + for _, backupResourceHookSpec := range spec.Hooks.Resources { + ResourceDetails := make(map[string]interface{}) + var s string + namespaceInfo := make(map[string]string) + if len(spec.IncludedNamespaces) == 0 { + s = "*" + } 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 + ResourceDetails["namespaces"] = namespaceInfo + + resourcesInfo := make(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 + ResourceDetails["resources"] = resourcesInfo + + s = emptyDisplay + if backupResourceHookSpec.LabelSelector != nil { + s = metav1.FormatLabelSelector(backupResourceHookSpec.LabelSelector) + } + ResourceDetails["labelSelector"] = s + + preHooks := make([]map[string]interface{}, 0) + for _, hook := range backupResourceHookSpec.PreHooks { + if hook.Exec != nil { + preExecHook := make(map[string]interface{}) + preExecHook["container"] = hook.Exec.Container + preExecHook["command"] = strings.Join(hook.Exec.Command, " ") + preExecHook["onError:"] = hook.Exec.OnError + preExecHook["timeout"] = hook.Exec.Timeout.Duration.String() + preHooks = append(preHooks, preExecHook) + } + } + ResourceDetails["preExecHook"] = preHooks + + postHooks := make([]map[string]interface{}, 0) + for _, hook := range backupResourceHookSpec.PostHooks { + if hook.Exec != nil { + postExecHook := make(map[string]interface{}) + postExecHook["container"] = hook.Exec.Container + postExecHook["command"] = strings.Join(hook.Exec.Command, " ") + postExecHook["onError:"] = hook.Exec.OnError + postExecHook["timeout"] = hook.Exec.Timeout.Duration.String() + postHooks = append(postHooks, postExecHook) + } + } + ResourceDetails["postExecHook"] = postHooks + hooksResources[backupResourceHookSpec.Name] = ResourceDetails + } + if len(spec.Hooks.Resources) > 0 { + hooksInfo["resources"] = hooksResources + backupSpecInfo["hooks"] = hooksInfo + } + + // desrcibe ordered resources + if spec.OrderedResources != nil { + backupSpecInfo["orderedResources"] = spec.OrderedResources + } + + d.Describe("spec", backupSpecInfo) +} + +// DescribeBackupStatusInSF describes a backup status in structured format. +func DescribeBackupStatusInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup, details bool, veleroClient clientset.Interface, insecureSkipTLSVerify bool, caCertPath string) { + status := backup.Status + backupStatusInfo := make(map[string]interface{}) + + // Status.Version has been deprecated, use Status.FormatVersion + backupStatusInfo["backupFormatVersion"] = status.FormatVersion + + // "" output should only be applicable for backups that failed validation + if status.StartTimestamp == nil || status.StartTimestamp.Time.IsZero() { + backupStatusInfo["started"] = "" + } else { + backupStatusInfo["started"] = status.StartTimestamp.Time.String() + } + if status.CompletionTimestamp == nil || status.CompletionTimestamp.Time.IsZero() { + backupStatusInfo["completed"] = "" + + } else { + backupStatusInfo["completed"] = status.CompletionTimestamp.Time.String() + } + + // Expiration can't be 0, it is always set to a 30-day default. It can be nil + // if the controller hasn't processed this Backup yet, in which case this will + // just display ``, though this should be temporary. + backupStatusInfo["expiration"] = status.Expiration.String() + + defer d.Describe("status", backupStatusInfo) + + if backup.Status.Progress != nil { + if backup.Status.Phase == velerov1api.BackupPhaseInProgress { + backupStatusInfo["estimatedTotalItemsToBeBackedUp"] = backup.Status.Progress.TotalItems + backupStatusInfo["itemsBackedUpSoFar"] = backup.Status.Progress.ItemsBackedUp + + } else { + backupStatusInfo["totalItemsToBeBackedUp"] = backup.Status.Progress.TotalItems + backupStatusInfo["itemsBackedUp"] = backup.Status.Progress.ItemsBackedUp + } + } + + if details { + describeBackupResourceListInSF(ctx, kbClient, backupStatusInfo, backup, insecureSkipTLSVerify, caCertPath) + } + + // In consideration of decoding structured output conveniently, the three separate fields were created here + // the field of "veleroNativeSnapshots" displays the brief snapshots info + // the field of "veleroNativeSnapshotsError" displays the error message if it fails to get snapshot info + // the field of "veleroNativeSnapshotsDetail" displays the detailed snapshots info + if status.VolumeSnapshotsAttempted > 0 { + if !details { + backupStatusInfo["veleroNativeSnapshots"] = fmt.Sprintf("%d of %d snapshots completed successfully", status.VolumeSnapshotsCompleted, status.VolumeSnapshotsAttempted) + return + } + + buf := new(bytes.Buffer) + if err := downloadrequest.Stream(ctx, kbClient, backup.Namespace, backup.Name, velerov1api.DownloadTargetKindBackupVolumeSnapshots, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath); err != nil { + backupStatusInfo["veleroNativeSnapshotsError"] = fmt.Sprintf("", err) + return + } + + var snapshots []*volume.Snapshot + if err := json.NewDecoder(buf).Decode(&snapshots); err != nil { + backupStatusInfo["veleroNativeSnapshotsError"] = fmt.Sprintf("", err) + return + } + + snapshotDetails := make(map[string]interface{}) + for _, snap := range snapshots { + describeSnapshotInSF(snap.Spec.PersistentVolumeName, snap.Status.ProviderSnapshotID, snap.Spec.VolumeType, snap.Spec.VolumeAZ, snap.Spec.VolumeIOPS, snapshotDetails) + } + backupStatusInfo["veleroNativeSnapshotsDetail"] = snapshotDetails + return + } + +} + +func describeBackupResourceListInSF(ctx context.Context, kbClient kbclient.Client, backupStatusInfo map[string]interface{}, backup *velerov1api.Backup, insecureSkipTLSVerify bool, caCertPath string) { + // In consideration of decoding structured output conveniently, the two separate fields were created here(in func describeBackupResourceList, there is only one field describing either error message or resource list) + // the field of 'resourceListError' gives specific error message when it fails to get resources list + // the field of 'resourceList' lists the rearranged resources + buf := new(bytes.Buffer) + if err := downloadrequest.Stream(ctx, kbClient, backup.Namespace, backup.Name, velerov1api.DownloadTargetKindBackupResourceList, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath); err != nil { + if err == downloadrequest.ErrNotFound { + // the backup resource list could be missing if (other reasons may exist as well): + // - the backup was taken prior to v1.1; or + // - the backup hasn't completed yet; or + // - there was an error uploading the file; or + // - the file was manually deleted after upload + backupStatusInfo["resourceListError"] = "" + } else { + backupStatusInfo["resourceListError"] = fmt.Sprintf("", err) + } + return + } + + var resourceList map[string][]string + if err := json.NewDecoder(buf).Decode(&resourceList); err != nil { + backupStatusInfo["resourceListError"] = fmt.Sprintf("\n", err) + return + } + backupStatusInfo["resourceList"] = resourceList +} + +func describeSnapshotInSF(pvName, snapshotID, volumeType, volumeAZ string, iops *int64, snapshotDetails map[string]interface{}) { + snapshotInfo := make(map[string]string) + iopsString := "" + if iops != nil { + iopsString = fmt.Sprintf("%d", *iops) + } + + snapshotInfo["Snapshot ID"] = snapshotID + snapshotInfo["Type"] = volumeType + snapshotInfo["Availability Zone"] = volumeAZ + snapshotInfo["IOPS"] = iopsString + snapshotDetails[pvName] = snapshotInfo + +} + +// DescribeDeleteBackupRequestsInSF describes delete backup requests in structured format. +func DescribeDeleteBackupRequestsInSF(d *StructuredDescriber, requests []velerov1api.DeleteBackupRequest) { + deletionAttempts := make(map[string]interface{}) + if count := failedDeletionCount(requests); count > 0 { + deletionAttempts["failed"] = count + } + + deletionRequests := make([]map[string]interface{}, 0) + for _, req := range requests { + deletionReq := make(map[string]interface{}) + deletionReq["creationTimestamp"] = req.CreationTimestamp.String() + deletionReq["phase"] = req.Status.Phase + + if len(req.Status.Errors) > 0 { + deletionReq["errors"] = req.Status.Errors + } + deletionRequests = append(deletionRequests, deletionReq) + } + deletionAttempts["deleteBackupRequests"] = deletionRequests + d.Describe("deletionAttempts", deletionAttempts) +} + +// DescribePodVolumeBackupsInSF describes pod volume backups in structured format. +func DescribePodVolumeBackupsInSF(d *StructuredDescriber, backups []velerov1api.PodVolumeBackup, details bool) { + PodVolumeBackupsInfo := make(map[string]interface{}) + // Get the type of pod volume uploader. Since the uploader only comes from a single source, we can + // take the uploader type from the first element of the array. + var uploaderType string + if len(backups) > 0 { + uploaderType = backups[0].Spec.UploaderType + } else { + return + } + // type display the type of pod volume backups + PodVolumeBackupsInfo["type"] = uploaderType + + podVolumeBackupsDetails := make(map[string]interface{}) + // separate backups by phase (combining and New into a single group) + backupsByPhase := groupByPhase(backups) + + // go through phases in a specific order + for _, phase := range []string{ + string(velerov1api.PodVolumeBackupPhaseCompleted), + string(velerov1api.PodVolumeBackupPhaseFailed), + "In Progress", + string(velerov1api.PodVolumeBackupPhaseNew), + } { + if len(backupsByPhase[phase]) == 0 { + continue + } + // if we're not printing details, just report the phase and count + if !details { + podVolumeBackupsDetails[phase] = len(backupsByPhase[phase]) + continue + } + // group the backups in the current phase by pod (i.e. "ns/name") + backupsByPod := new(volumesByPod) + for _, backup := range backupsByPhase[phase] { + backupsByPod.Add(backup.Spec.Pod.Namespace, backup.Spec.Pod.Name, backup.Spec.Volume, phase, backup.Status.Progress) + } + + backupsByPods := make([]map[string]string, 0) + for _, backupGroup := range backupsByPod.volumesByPodSlice { + // print volumes backed up for this pod + backupsByPods = append(backupsByPods, map[string]string{backupGroup.label: strings.Join(backupGroup.volumes, ", ")}) + } + podVolumeBackupsDetails[phase] = backupsByPods + } + // Pod Volume Backups Details display the detailed pod volume backups info + PodVolumeBackupsInfo["podVolumeBackupsDetails"] = podVolumeBackupsDetails + d.Describe("podVolumeBackups", PodVolumeBackupsInfo) +} + +// DescribeCSIVolumeSnapshotsInSF describes CSI volume snapshots in structured format. +func DescribeCSIVolumeSnapshotsInSF(d *StructuredDescriber, details bool, volumeSnapshotContents []snapshotv1api.VolumeSnapshotContent) { + CSIVolumeSnapshotsInfo := make(map[string]interface{}) + if !features.IsEnabled(velerov1api.CSIFeatureFlag) { + return + } + + if len(volumeSnapshotContents) == 0 { + return + } + + // In consideration of decoding structured output conveniently, the two separate fields were created here + // the field of 'CSI Volume Snapshots Count' displays the count of CSI Volume Snapshots + // the field of 'CSI Volume Snapshots Details' displays the content of CSI Volume Snapshots + if !details { + CSIVolumeSnapshotsInfo["CSIVolumeSnapshotsCount"] = len(volumeSnapshotContents) + return + } + + vscDetails := make(map[string]interface{}) + for _, vsc := range volumeSnapshotContents { + DescribeVSCInSF(details, vsc, vscDetails) + } + CSIVolumeSnapshotsInfo["CSIVolumeSnapshotsDetails"] = vscDetails + d.Describe("CSIVolumeSnapshots", CSIVolumeSnapshotsInfo) +} + +// DescribeVSCInSF describes CSI volume snapshot contents in structured format. +func DescribeVSCInSF(details bool, vsc snapshotv1api.VolumeSnapshotContent, vscDetails map[string]interface{}) { + content := make(map[string]interface{}) + if vsc.Status == nil { + vscDetails[vsc.Name] = content + return + } + + if vsc.Status.SnapshotHandle != nil { + content["storageSnapshotID"] = *vsc.Status.SnapshotHandle + } + + if vsc.Status.RestoreSize != nil { + content["snapshotSize(bytes)"] = *vsc.Status.RestoreSize + + } + + if vsc.Status.ReadyToUse != nil { + content["readyToUse"] = *vsc.Status.ReadyToUse + } + vscDetails[vsc.Name] = content +} diff --git a/pkg/cmd/util/output/describe.go b/pkg/cmd/util/output/describe.go index 5e9afd7086..61aac2fce4 100644 --- a/pkg/cmd/util/output/describe.go +++ b/pkg/cmd/util/output/describe.go @@ -18,6 +18,7 @@ package output import ( "bytes" + "encoding/json" "fmt" "sort" "strings" @@ -46,6 +47,15 @@ func Describe(fn func(d *Describer)) string { return d.buf.String() } +func NewDescriber(minwidth, tabwidth, padding int, padchar byte, flags uint) *Describer { + d := &Describer{ + out: new(tabwriter.Writer), + buf: new(bytes.Buffer), + } + d.out.Init(d.buf, minwidth, tabwidth, padding, padchar, flags) + return d +} + func (d *Describer) Printf(msg string, args ...interface{}) { fmt.Fprint(d.out, d.Prefix) fmt.Fprintf(d.out, msg, args...) @@ -119,3 +129,50 @@ func BoolPointerString(b *bool, falseString, trueString, nilString string) strin } return falseString } + +type StructuredDescriber struct { + output map[string]interface{} + format string +} + +// NewStructuredDescriber creates a StructuredDescriber. +func NewStructuredDescriber(format string) *StructuredDescriber { + return &StructuredDescriber{ + output: make(map[string]interface{}), + format: format, + } +} + +// DescribeInSF returns the structured output based on the func +// that applies StructuredDescriber to collect outputs. +// This function takes arg 'format' for future format extension. +func DescribeInSF(fn func(d *StructuredDescriber), format string) string { + d := NewStructuredDescriber(format) + fn(d) + return d.JsonEncode() +} + +// Describe adds all types of argument to d.output. +func (d *StructuredDescriber) Describe(name string, arg interface{}) { + d.output[name] = arg +} + +// DescribeMetadata describes standard object metadata. +func (d *StructuredDescriber) DescribeMetadata(metadata metav1.ObjectMeta) { + metadataInfo := make(map[string]interface{}) + metadataInfo["name"] = metadata.Name + metadataInfo["namespace"] = metadata.Namespace + metadataInfo["labels"] = metadata.Labels + metadataInfo["annotations"] = metadata.Annotations + d.Describe("metadata", metadataInfo) +} + +// JsonEncode encodes d.output to json +func (d *StructuredDescriber) JsonEncode() string { + byteBuffer := &bytes.Buffer{} + encoder := json.NewEncoder(byteBuffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + _ = encoder.Encode(d.output) + return byteBuffer.String() +}