diff --git a/cmd/non-admin/backup/backup_test.go b/cmd/non-admin/backup/backup_test.go index 8f39f3e..fe1da13 100644 --- a/cmd/non-admin/backup/backup_test.go +++ b/cmd/non-admin/backup/backup_test.go @@ -51,7 +51,6 @@ func TestNonAdminBackupCommands(t *testing.T) { "--storage-location", "--include-resources", "--exclude-resources", - "--wait", "--force", "--assume-yes", }, @@ -251,7 +250,6 @@ func TestNonAdminBackupCreateFlags(t *testing.T) { "--exclude-resources", "--labels", "--annotations", - "--wait", "--force", "--assume-yes", "--snapshot-volumes", @@ -276,7 +274,6 @@ func TestNonAdminBackupExamples(t *testing.T) { "--storage-location", "--include-resources", "--exclude-resources", - "--wait", } testutil.TestHelpCommand(t, binaryPath, diff --git a/cmd/non-admin/backup/create.go b/cmd/non-admin/backup/create.go index fac2067..44d1f4d 100644 --- a/cmd/non-admin/backup/create.go +++ b/cmd/non-admin/backup/create.go @@ -26,7 +26,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "k8s.io/client-go/tools/cache" kbclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/migtools/oadp-cli/cmd/shared" @@ -37,7 +36,6 @@ import ( "github.com/vmware-tanzu/velero/pkg/cmd" "github.com/vmware-tanzu/velero/pkg/cmd/util/flag" "github.com/vmware-tanzu/velero/pkg/cmd/util/output" - "github.com/vmware-tanzu/velero/pkg/util/kube" ) func NewCreateCommand(f client.Factory, use string) *cobra.Command { @@ -68,14 +66,10 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command { kubectl oadp nonadmin backup create backup5 --force --assume-yes # View the YAML for a non-admin backup that doesn't snapshot volumes, without sending it to the server. - kubectl oadp nonadmin backup create backup6 --snapshot-volumes=false --storage-location my-nabsl -o yaml - - # Wait for a non-admin backup to complete before returning from the command. - kubectl oadp nonadmin backup create backup7 --wait --storage-location my-nabsl`, + kubectl oadp nonadmin backup create backup6 --snapshot-volumes=false --storage-location my-nabsl -o yaml`, } o.BindFlags(c.Flags()) - o.BindWait(c.Flags()) o.BindFromSchedule(c.Flags()) output.BindFlags(c.Flags()) output.ClearOutputFlagDefault(c) @@ -101,7 +95,6 @@ type CreateOptions struct { Selector flag.LabelSelector OrSelector flag.OrLabelSelector IncludeClusterResources flag.OptionalBool - Wait bool StorageLocation string SnapshotLocations []string FromSchedule string @@ -164,12 +157,6 @@ func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { flags.BoolVarP(&o.AssumeYes, "assume-yes", "y", o.AssumeYes, "Assume yes to all prompts and run non-interactively.") } -// BindWait binds the wait flag separately so it is not called by other create -// commands that reuse CreateOptions's BindFlags method. -func (o *CreateOptions) BindWait(flags *pflag.FlagSet) { - flags.BoolVarP(&o.Wait, "wait", "w", o.Wait, "Wait for the operation to complete.") -} - // BindFromSchedule binds the from-schedule flag separately so it is not called // by other create commands that reuse CreateOptions's BindFlags method. func (o *CreateOptions) BindFromSchedule(flags *pflag.FlagSet) { @@ -261,121 +248,18 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { fmt.Println("Creating non-admin backup from schedule, all other filters are ignored.") } - // Warning prompt when using force flag without storage location - if o.Force && o.StorageLocation == "" { - fmt.Println("\nWARNING: Using --force without specifying a storage location is not ideal.") - fmt.Println("This will use admin defaults and certain features like logs may not work as expected.") - - if !o.AssumeYes { - fmt.Print("Do you want to continue? (y/N): ") - - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read user input: %w", err) - } - - response = strings.TrimSpace(strings.ToLower(response)) - if response != "y" && response != "yes" { - fmt.Println("Operation cancelled.") - return nil - } - } else { - fmt.Println("Proceeding with --assume-yes flag.") - } - fmt.Println() // Add blank line for better formatting - } - - var updates chan *nacv1alpha1.NonAdminBackup - if o.Wait { - stop := make(chan struct{}) - defer close(stop) - - updates = make(chan *nacv1alpha1.NonAdminBackup) - - lw := kube.InternalLW{ - Client: o.client, - Namespace: o.currentNamespace, - ObjectList: new(nacv1alpha1.NonAdminBackupList), - } - backupInformer := cache.NewSharedInformer(&lw, &nacv1alpha1.NonAdminBackup{}, time.Second) - _, _ = backupInformer.AddEventHandler( - cache.FilteringResourceEventHandler{ - FilterFunc: func(obj any) bool { - backup, ok := obj.(*nacv1alpha1.NonAdminBackup) - - if !ok { - return false - } - return backup.Name == o.Name - }, - Handler: cache.ResourceEventHandlerFuncs{ - UpdateFunc: func(_, obj any) { - backup, ok := obj.(*nacv1alpha1.NonAdminBackup) - if !ok { - return - } - updates <- backup - }, - DeleteFunc: func(obj any) { - backup, ok := obj.(*nacv1alpha1.NonAdminBackup) - if !ok { - return - } - updates <- backup - }, - }, - }, - ) - - go backupInformer.Run(stop) - } - - err = o.client.Create(context.TODO(), nonAdminBackup, &kbclient.CreateOptions{}) - if err != nil { + // Prompt for confirmation if using force without storage location + if err := o.promptForceConfirmation(); err != nil { return err } - if o.Force && o.StorageLocation == "" { - fmt.Printf("NonAdminBackup request %q submitted successfully (using admin defaults).\n", nonAdminBackup.Name) - } else { - fmt.Printf("NonAdminBackup request %q submitted successfully.\n", nonAdminBackup.Name) - } - if o.Wait { - fmt.Println("Waiting for non-admin backup to complete. You may safely press ctrl-c to stop waiting - your backup will continue in the background.") - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - fmt.Print(".") - case backup, ok := <-updates: - if !ok { - fmt.Println("\nError waiting: unable to watch non-admin backups.") - return nil - } - - // Check NonAdminBackup status phase for completion states - if backup.Status.Phase == "BackupDone" || backup.Status.Phase == "BackupFailed" { - if o.Force && o.StorageLocation == "" { - fmt.Printf("\nNonAdminBackup completed with status: %s (using admin defaults). You may check for more information using the commands `oadp nonadmin backup describe %s` and `oadp nonadmin backup logs %s`.\n", backup.Status.Phase, backup.Name, backup.Name) - } else { - fmt.Printf("\nNonAdminBackup completed with status: %s. You may check for more information using the commands `oadp nonadmin backup describe %s` and `oadp nonadmin backup logs %s`.\n", backup.Status.Phase, backup.Name, backup.Name) - } - return nil - } - } - } - } - - // Not waiting - if o.Force && o.StorageLocation == "" { - fmt.Printf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details. (Created using admin defaults)\n", nonAdminBackup.Name, nonAdminBackup.Name) - } else { - fmt.Printf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details.\n", nonAdminBackup.Name, nonAdminBackup.Name) + // Create the backup + if err := o.client.Create(context.TODO(), nonAdminBackup, &kbclient.CreateOptions{}); err != nil { + return err } + fmt.Println(o.formatSuccessMessage(nonAdminBackup.Name)) + fmt.Print(o.formatCommandSuggestion(nonAdminBackup.Name)) return nil } @@ -402,76 +286,102 @@ func ParseOrderedResources(orderMapStr string) (map[string]string, error) { } func (o *CreateOptions) BuildNonAdminBackup(namespace string) (*nacv1alpha1.NonAdminBackup, error) { - // Create the underlying Velero BackupSpec var backupSpec *velerov1api.BackupSpec + var err error if o.FromSchedule != "" { - schedule := new(velerov1api.Schedule) - err := o.client.Get(context.TODO(), kbclient.ObjectKey{Namespace: namespace, Name: o.FromSchedule}, schedule) - if err != nil { - return nil, err - } - if o.Name == "" { - o.Name = schedule.TimestampedName(time.Now().UTC()) - } - backupSpec = &schedule.Spec.Template + backupSpec, err = o.buildBackupSpecFromSchedule(namespace) } else { - // Build the BackupSpec manually - // For NonAdminBackup, automatically include the current namespace - - storageLocation := o.StorageLocation - - backupBuilder := builder.ForBackup(namespace, o.Name). - IncludedNamespaces(namespace). // Automatically include the current namespace - IncludedResources(o.IncludeResources...). - ExcludedResources(o.ExcludeResources...). - IncludedClusterScopedResources(o.IncludeClusterScopedResources...). - ExcludedClusterScopedResources(o.ExcludeClusterScopedResources...). - IncludedNamespaceScopedResources(o.IncludeNamespaceScopedResources...). - ExcludedNamespaceScopedResources(o.ExcludeNamespaceScopedResources...). - LabelSelector(o.Selector.LabelSelector). - OrLabelSelector(o.OrSelector.OrLabelSelectors). - TTL(o.TTL). - StorageLocation(storageLocation). - VolumeSnapshotLocations(o.SnapshotLocations...). - CSISnapshotTimeout(o.CSISnapshotTimeout). - ItemOperationTimeout(o.ItemOperationTimeout). - DataMover(o.DataMover) - - if len(o.OrderedResources) > 0 { - orders, err := ParseOrderedResources(o.OrderedResources) - if err != nil { - return nil, err - } - backupBuilder.OrderedResources(orders) - } + backupSpec, err = o.buildBackupSpecFromOptions(namespace) + } - if o.SnapshotVolumes.Value != nil { - backupBuilder.SnapshotVolumes(*o.SnapshotVolumes.Value) - } - if o.SnapshotMoveData.Value != nil { - backupBuilder.SnapshotMoveData(*o.SnapshotMoveData.Value) - } - if o.IncludeClusterResources.Value != nil { - backupBuilder.IncludeClusterResources(*o.IncludeClusterResources.Value) - } - if o.DefaultVolumesToFsBackup.Value != nil { - backupBuilder.DefaultVolumesToFsBackup(*o.DefaultVolumesToFsBackup.Value) - } - if o.ResPoliciesConfigmap != "" { - backupBuilder.ResourcePolicies(o.ResPoliciesConfigmap) - } - if o.ParallelFilesUpload > 0 { - backupBuilder.ParallelFilesUpload(o.ParallelFilesUpload) + if err != nil { + return nil, err + } + + return o.createNonAdminBackup(namespace, backupSpec), nil +} + +// buildBackupSpecFromSchedule creates a BackupSpec from an existing schedule +func (o *CreateOptions) buildBackupSpecFromSchedule(namespace string) (*velerov1api.BackupSpec, error) { + schedule := new(velerov1api.Schedule) + err := o.client.Get(context.TODO(), kbclient.ObjectKey{Namespace: namespace, Name: o.FromSchedule}, schedule) + if err != nil { + return nil, err + } + + if o.Name == "" { + o.Name = schedule.TimestampedName(time.Now().UTC()) + } + + return &schedule.Spec.Template, nil +} + +// buildBackupSpecFromOptions creates a BackupSpec from command line options +func (o *CreateOptions) buildBackupSpecFromOptions(namespace string) (*velerov1api.BackupSpec, error) { + backupBuilder := builder.ForBackup(namespace, o.Name). + IncludedNamespaces(namespace). // Automatically include the current namespace + IncludedResources(o.IncludeResources...). + ExcludedResources(o.ExcludeResources...). + IncludedClusterScopedResources(o.IncludeClusterScopedResources...). + ExcludedClusterScopedResources(o.ExcludeClusterScopedResources...). + IncludedNamespaceScopedResources(o.IncludeNamespaceScopedResources...). + ExcludedNamespaceScopedResources(o.ExcludeNamespaceScopedResources...). + LabelSelector(o.Selector.LabelSelector). + OrLabelSelector(o.OrSelector.OrLabelSelectors). + TTL(o.TTL). + StorageLocation(o.StorageLocation). + VolumeSnapshotLocations(o.SnapshotLocations...). + CSISnapshotTimeout(o.CSISnapshotTimeout). + ItemOperationTimeout(o.ItemOperationTimeout). + DataMover(o.DataMover) + + if err := o.applyOptionalBackupOptions(backupBuilder); err != nil { + return nil, err + } + + tempBackup := backupBuilder. + ObjectMeta(builder.WithLabelsMap(o.Labels.Data()), builder.WithAnnotationsMap(o.Annotations.Data())). + Result() + + return &tempBackup.Spec, nil +} + +// applyOptionalBackupOptions applies optional flags to the backup builder +func (o *CreateOptions) applyOptionalBackupOptions(backupBuilder *builder.BackupBuilder) error { + if len(o.OrderedResources) > 0 { + orders, err := ParseOrderedResources(o.OrderedResources) + if err != nil { + return err } + backupBuilder.OrderedResources(orders) + } - // Get the built backup spec - tempBackup := backupBuilder.ObjectMeta(builder.WithLabelsMap(o.Labels.Data()), builder.WithAnnotationsMap(o.Annotations.Data())).Result() - backupSpec = &tempBackup.Spec + if o.SnapshotVolumes.Value != nil { + backupBuilder.SnapshotVolumes(*o.SnapshotVolumes.Value) + } + if o.SnapshotMoveData.Value != nil { + backupBuilder.SnapshotMoveData(*o.SnapshotMoveData.Value) } + if o.IncludeClusterResources.Value != nil { + backupBuilder.IncludeClusterResources(*o.IncludeClusterResources.Value) + } + if o.DefaultVolumesToFsBackup.Value != nil { + backupBuilder.DefaultVolumesToFsBackup(*o.DefaultVolumesToFsBackup.Value) + } + if o.ResPoliciesConfigmap != "" { + backupBuilder.ResourcePolicies(o.ResPoliciesConfigmap) + } + if o.ParallelFilesUpload > 0 { + backupBuilder.ParallelFilesUpload(o.ParallelFilesUpload) + } + + return nil +} - // Create NonAdminBackup using the builder - nonAdminBackup := ForNonAdminBackup(namespace, o.Name). +// createNonAdminBackup creates the NonAdminBackup CR from a BackupSpec +func (o *CreateOptions) createNonAdminBackup(namespace string, backupSpec *velerov1api.BackupSpec) *nacv1alpha1.NonAdminBackup { + return ForNonAdminBackup(namespace, o.Name). ObjectMeta( WithLabelsMap(o.Labels.Data()), WithAnnotationsMap(o.Annotations.Data()), @@ -480,8 +390,6 @@ func (o *CreateOptions) BuildNonAdminBackup(namespace string) (*nacv1alpha1.NonA BackupSpec: backupSpec, }). Result() - - return nonAdminBackup, nil } func (o *CreateOptions) oldAndNewFilterParametersUsedTogether() bool { @@ -495,3 +403,57 @@ func (o *CreateOptions) oldAndNewFilterParametersUsedTogether() bool { return haveOldResourceFilterParameters && haveNewResourceFilterParameters } + +// promptForceConfirmation prompts the user to confirm when using --force without storage location +func (o *CreateOptions) promptForceConfirmation() error { + if !o.Force || o.StorageLocation != "" { + return nil + } + + fmt.Println("\nWARNING: Using --force without specifying a storage location is not ideal.") + fmt.Println("This will use admin defaults and certain features like logs may not work as expected.") + + if o.AssumeYes { + fmt.Println("Proceeding with --assume-yes flag.") + fmt.Println() + return nil + } + + fmt.Print("Do you want to continue? (y/N): ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Operation cancelled.") + return fmt.Errorf("operation cancelled by user") + } + + fmt.Println() + return nil +} + +// usingAdminDefaults returns true if force flag is used without storage location +func (o *CreateOptions) usingAdminDefaults() bool { + return o.Force && o.StorageLocation == "" +} + +// formatSuccessMessage returns the success message with optional admin defaults note +func (o *CreateOptions) formatSuccessMessage(name string) string { + if o.usingAdminDefaults() { + return fmt.Sprintf("NonAdminBackup request %q submitted successfully (using admin defaults).", name) + } + return fmt.Sprintf("NonAdminBackup request %q submitted successfully.", name) +} + +// formatCommandSuggestion returns the command suggestion with optional admin defaults note +func (o *CreateOptions) formatCommandSuggestion(name string) string { + baseMsg := fmt.Sprintf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details.", name, name) + if o.usingAdminDefaults() { + return fmt.Sprintf("%s (Created using admin defaults)\n", baseMsg) + } + return baseMsg + "\n" +} diff --git a/cmd/non-admin/backup/describe.go b/cmd/non-admin/backup/describe.go index fa90109..1215cd7 100644 --- a/cmd/non-admin/backup/describe.go +++ b/cmd/non-admin/backup/describe.go @@ -1,11 +1,8 @@ package backup import ( - "compress/gzip" "context" "fmt" - "io" - "net/http" "sort" "strings" "time" @@ -13,12 +10,10 @@ import ( "github.com/migtools/oadp-cli/cmd/shared" nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" "github.com/spf13/cobra" - velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/client" "github.com/vmware-tanzu/velero/pkg/cmd/util/output" "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kbclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -46,126 +41,311 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { return err } - // Shows NonAdminBackup resources - var nabList nacv1alpha1.NonAdminBackupList - if err := kbClient.List(context.Background(), &nabList, &kbclient.ListOptions{ + // Get the specific backup + var nab nacv1alpha1.NonAdminBackup + if err := kbClient.Get(context.Background(), kbclient.ObjectKey{ Namespace: userNamespace, - }); err != nil { - return fmt.Errorf("failed to list NonAdminBackup: %w", err) + Name: backupName, + }, &nab); err != nil { + return fmt.Errorf("NonAdminBackup %q not found in namespace %q: %w", backupName, userNamespace, err) } - // Find the specific backup - var targetBackup *nacv1alpha1.NonAdminBackup - for i := range nabList.Items { - if nabList.Items[i].Name == backupName { - targetBackup = &nabList.Items[i] - break - } - } + // Print in Velero-style format + printNonAdminBackupDetails(cmd, &nab) - if targetBackup == nil { - return fmt.Errorf("NonAdminBackup %q not found in namespace %q", backupName, userNamespace) - } + return nil + }, + Example: ` kubectl oadp nonadmin backup describe my-backup`, + } - // Print basic info - fmt.Printf("Name:\t%s\n", targetBackup.Name) - fmt.Printf("Namespace:\t%s\n", targetBackup.Namespace) - - // Print labels if any - if len(targetBackup.Labels) > 0 { - fmt.Printf("Labels:\t") - var labelPairs []string - for k, v := range targetBackup.Labels { - labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) - } - sort.Strings(labelPairs) - fmt.Printf("%s\n", strings.Join(labelPairs, ",")) + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +// printNonAdminBackupDetails prints backup details in Velero admin describe format +func printNonAdminBackupDetails(cmd *cobra.Command, nab *nacv1alpha1.NonAdminBackup) { + out := cmd.OutOrStdout() + + // Get Velero backup reference if available + var vb *nacv1alpha1.VeleroBackup + if nab.Status.VeleroBackup != nil { + vb = nab.Status.VeleroBackup + } + + // Name and Namespace + fmt.Fprintf(out, "Name: %s\n", nab.Name) + fmt.Fprintf(out, "Namespace: %s\n", nab.Namespace) + + // Labels + fmt.Fprintf(out, "Labels: ") + if len(nab.Labels) == 0 { + fmt.Fprintf(out, "\n") + } else { + labelKeys := make([]string, 0, len(nab.Labels)) + for k := range nab.Labels { + labelKeys = append(labelKeys, k) + } + sort.Strings(labelKeys) + for i, k := range labelKeys { + if i == 0 { + fmt.Fprintf(out, "%s=%s\n", k, nab.Labels[k]) } else { - fmt.Printf("Labels:\t\n") + fmt.Fprintf(out, " %s=%s\n", k, nab.Labels[k]) } + } + } - // Print annotations if any - if len(targetBackup.Annotations) > 0 { - fmt.Printf("Annotations:\t") - var annotationPairs []string - for k, v := range targetBackup.Annotations { - annotationPairs = append(annotationPairs, fmt.Sprintf("%s=%s", k, v)) - } - sort.Strings(annotationPairs) - fmt.Printf("%s\n", strings.Join(annotationPairs, ",")) + // Annotations + fmt.Fprintf(out, "Annotations: ") + if len(nab.Annotations) == 0 { + fmt.Fprintf(out, "\n") + } else { + annotationKeys := make([]string, 0, len(nab.Annotations)) + for k := range nab.Annotations { + annotationKeys = append(annotationKeys, k) + } + sort.Strings(annotationKeys) + for i, k := range annotationKeys { + if i == 0 { + fmt.Fprintf(out, "%s=%s\n", k, nab.Annotations[k]) } else { - fmt.Printf("Annotations:\t\n") + fmt.Fprintf(out, " %s=%s\n", k, nab.Annotations[k]) } + } + } + + fmt.Fprintf(out, "\n") + + // Phase (with color) + phase := string(nab.Status.Phase) + if vb != nil && vb.Status != nil && vb.Status.Phase != "" { + phase = string(vb.Status.Phase) + } + fmt.Fprintf(out, "Phase: %s\n", colorizePhase(phase)) + + fmt.Fprintf(out, "\n") - // Print phase/status - fmt.Printf("Phase:\t%s\n", targetBackup.Status.Phase) - - // Print conditions - if len(targetBackup.Status.Conditions) > 0 { - fmt.Printf("Conditions:\n") - for _, condition := range targetBackup.Status.Conditions { - fmt.Printf(" Type:\t%s\n", condition.Type) - fmt.Printf(" Status:\t%s\n", condition.Status) - if condition.Reason != "" { - fmt.Printf(" Reason:\t%s\n", condition.Reason) - } - if condition.Message != "" { - fmt.Printf(" Message:\t%s\n", condition.Message) - } - fmt.Printf(" Last Transition Time:\t%s\n", condition.LastTransitionTime.Format(time.RFC3339)) - fmt.Printf("\n") - } + // Backup Spec details + if nab.Spec.BackupSpec != nil { + spec := nab.Spec.BackupSpec + + // Namespaces + fmt.Fprintf(out, "Namespaces:\n") + if len(spec.IncludedNamespaces) == 0 { + fmt.Fprintf(out, " Included: *\n") + } else { + fmt.Fprintf(out, " Included: %s\n", strings.Join(spec.IncludedNamespaces, ", ")) + } + if len(spec.ExcludedNamespaces) == 0 { + fmt.Fprintf(out, " Excluded: \n") + } else { + fmt.Fprintf(out, " Excluded: %s\n", strings.Join(spec.ExcludedNamespaces, ", ")) + } + + fmt.Fprintf(out, "\n") + + // Resources + fmt.Fprintf(out, "Resources:\n") + if len(spec.IncludedResources) == 0 { + fmt.Fprintf(out, " Included: *\n") + } else { + fmt.Fprintf(out, " Included: %s\n", strings.Join(spec.IncludedResources, ", ")) + } + if len(spec.ExcludedResources) == 0 { + fmt.Fprintf(out, " Excluded: \n") + } else { + fmt.Fprintf(out, " Excluded: %s\n", strings.Join(spec.ExcludedResources, ", ")) + } + if spec.IncludeClusterResources != nil { + if *spec.IncludeClusterResources { + fmt.Fprintf(out, " Cluster-scoped: included\n") + } else { + fmt.Fprintf(out, " Cluster-scoped: excluded\n") } + } else { + fmt.Fprintf(out, " Cluster-scoped: auto\n") + } - // Print related Velero backup info if available - if targetBackup.Status.VeleroBackup != nil { - fmt.Printf("Velero Backup:\n") - fmt.Printf(" Name:\t%s\n", targetBackup.Status.VeleroBackup.Name) - fmt.Printf(" Namespace:\t%s\n", targetBackup.Status.VeleroBackup.Namespace) - if targetBackup.Status.VeleroBackup.Status != nil { - fmt.Printf(" Status:\n") - // Print some key status fields - if targetBackup.Status.VeleroBackup.Status.Phase != "" { - fmt.Printf(" Phase:\t%s\n", targetBackup.Status.VeleroBackup.Status.Phase) - } - if !targetBackup.Status.VeleroBackup.Status.StartTimestamp.IsZero() { - fmt.Printf(" Start Time:\t%s\n", targetBackup.Status.VeleroBackup.Status.StartTimestamp.Format(time.RFC3339)) - } - if !targetBackup.Status.VeleroBackup.Status.CompletionTimestamp.IsZero() { - fmt.Printf(" Completion Time:\t%s\n", targetBackup.Status.VeleroBackup.Status.CompletionTimestamp.Format(time.RFC3339)) - } - if targetBackup.Status.VeleroBackup.Status.Expiration != nil { - fmt.Printf(" Expiration:\t%s\n", targetBackup.Status.VeleroBackup.Status.Expiration.Format(time.RFC3339)) - } - } + fmt.Fprintf(out, "\n") + + // Label selector + if spec.LabelSelector != nil && len(spec.LabelSelector.MatchLabels) > 0 { + var selectorParts []string + for k, v := range spec.LabelSelector.MatchLabels { + selectorParts = append(selectorParts, fmt.Sprintf("%s=%s", k, v)) } + fmt.Fprintf(out, "Label selector: %s\n", strings.Join(selectorParts, ",")) + } else { + fmt.Fprintf(out, "Label selector: \n") + } + + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "Or label selector: \n") + fmt.Fprintf(out, "\n") - // Print the spec (what was requested) - if targetBackup.Spec.BackupSpec != nil { - fmt.Printf("\nBackup Spec:\n") - specBytes, err := yaml.Marshal(targetBackup.Spec.BackupSpec) - if err != nil { - fmt.Printf(" Error marshaling spec: %v\n", err) - } else { - // Indent the YAML output - specLines := strings.Split(string(specBytes), "\n") - for _, line := range specLines { - if line != "" { - fmt.Printf(" %s\n", line) - } - } - } + // Storage Location + if spec.StorageLocation != "" { + fmt.Fprintf(out, "Storage Location: %s\n", spec.StorageLocation) + } else { + fmt.Fprintf(out, "Storage Location: \n") + } + + fmt.Fprintf(out, "\n") + + // Snapshot settings + if spec.SnapshotVolumes != nil { + if *spec.SnapshotVolumes { + fmt.Fprintf(out, "Velero-Native Snapshot PVs: true\n") + } else { + fmt.Fprintf(out, "Velero-Native Snapshot PVs: false\n") } + } else { + fmt.Fprintf(out, "Velero-Native Snapshot PVs: auto\n") + } - return nil - }, - Example: ` kubectl oadp nonadmin backup describe my-backup`, + if spec.SnapshotMoveData != nil && *spec.SnapshotMoveData { + fmt.Fprintf(out, "Snapshot Move Data: true\n") + } else { + fmt.Fprintf(out, "Snapshot Move Data: false\n") + } + + if spec.DataMover != "" { + fmt.Fprintf(out, "Data Mover: %s\n", spec.DataMover) + } else { + fmt.Fprintf(out, "Data Mover: velero\n") + } + + fmt.Fprintf(out, "\n") + + // TTL + if spec.TTL.Duration > 0 { + fmt.Fprintf(out, "TTL: %s\n", spec.TTL.Duration) + } else { + fmt.Fprintf(out, "TTL: 720h0m0s\n") // default + } + + fmt.Fprintf(out, "\n") + + // Timeouts + if spec.CSISnapshotTimeout.Duration > 0 { + fmt.Fprintf(out, "CSISnapshotTimeout: %s\n", spec.CSISnapshotTimeout.Duration) + } else { + fmt.Fprintf(out, "CSISnapshotTimeout: 10m0s\n") + } + + if spec.ItemOperationTimeout.Duration > 0 { + fmt.Fprintf(out, "ItemOperationTimeout: %s\n", spec.ItemOperationTimeout.Duration) + } else { + fmt.Fprintf(out, "ItemOperationTimeout: 4h0m0s\n") + } + + fmt.Fprintf(out, "\n") + + // Hooks + if len(spec.Hooks.Resources) > 0 { + fmt.Fprintf(out, "Hooks: %d resources with hooks\n", len(spec.Hooks.Resources)) + } else { + fmt.Fprintf(out, "Hooks: \n") + } + + fmt.Fprintf(out, "\n") } - output.BindFlags(c.Flags()) - output.ClearOutputFlagDefault(c) + // Velero backup status information + if vb != nil && vb.Status != nil { + status := vb.Status - return c + // Backup Format Version + if status.FormatVersion != "" { + fmt.Fprintf(out, "Backup Format Version: %s\n", status.FormatVersion) + } + + fmt.Fprintf(out, "\n") + + // Started and Completed times + if !status.StartTimestamp.IsZero() { + fmt.Fprintf(out, "Started: %s\n", status.StartTimestamp.Time.Format("2006-01-02 15:04:05 -0700 MST")) + } + if !status.CompletionTimestamp.IsZero() { + fmt.Fprintf(out, "Completed: %s\n", status.CompletionTimestamp.Time.Format("2006-01-02 15:04:05 -0700 MST")) + } + + fmt.Fprintf(out, "\n") + + // Expiration + if status.Expiration != nil { + fmt.Fprintf(out, "Expiration: %s\n", status.Expiration.Format("2006-01-02 15:04:05 -0700 MST")) + } + + fmt.Fprintf(out, "\n") + + // Progress + if status.Progress != nil { + fmt.Fprintf(out, "Total items to be backed up: %d\n", status.Progress.TotalItems) + fmt.Fprintf(out, "Items backed up: %d\n", status.Progress.ItemsBackedUp) + } + + fmt.Fprintf(out, "\n") + + // Backup Volumes + fmt.Fprintf(out, "Backup Volumes:\n") + + hasVeleroSnapshots := status.VolumeSnapshotsAttempted > 0 + if hasVeleroSnapshots { + fmt.Fprintf(out, " Velero-Native Snapshots: %d of %d snapshots completed successfully (specify --details for more information)\n", + status.VolumeSnapshotsCompleted, status.VolumeSnapshotsAttempted) + } else { + fmt.Fprintf(out, " Velero-Native Snapshots: \n") + } + + fmt.Fprintf(out, "\n") + + hasCSISnapshots := status.CSIVolumeSnapshotsAttempted > 0 + if hasCSISnapshots { + fmt.Fprintf(out, " CSI Snapshots: %d of %d snapshots completed successfully\n", + status.CSIVolumeSnapshotsCompleted, status.CSIVolumeSnapshotsAttempted) + } else { + fmt.Fprintf(out, " CSI Snapshots: \n") + } + + fmt.Fprintf(out, "\n") + + // Pod Volume Backups + fmt.Fprintf(out, " Pod Volume Backups: \n") + + fmt.Fprintf(out, "\n") + + // Hooks + fmt.Fprintf(out, "HooksAttempted: %d\n", status.HookStatus.HooksAttempted) + fmt.Fprintf(out, "HooksFailed: %d\n", status.HookStatus.HooksFailed) + } else { + // Velero backup not available yet + fmt.Fprintf(out, "Velero backup information not yet available.\n") + fmt.Fprintf(out, "Request Phase: %s\n", nab.Status.Phase) + } +} + +// colorizePhase returns the phase string with ANSI color codes +func colorizePhase(phase string) string { + const ( + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorRed = "\033[31m" + colorReset = "\033[0m" + ) + + switch phase { + case "Completed": + return colorGreen + phase + colorReset + case "InProgress", "New": + return colorYellow + phase + colorReset + case "Failed", "FailedValidation", "PartiallyFailed": + return colorRed + phase + colorReset + default: + return phase + } } // NonAdminDescribeBackup mirrors Velero's output.DescribeBackup functionality @@ -220,25 +400,41 @@ func NonAdminDescribeBackup(cmd *cobra.Command, kbClient kbclient.Client, nab *n fmt.Fprintf(cmd.OutOrStdout(), "\nFetching additional backup details...") // Get backup results using NonAdminDownloadRequest (most important data) - if results, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResults"); err == nil { + if results, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: veleroBackupName, + DataType: "BackupResults", + Namespace: userNamespace, + }); err == nil { fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Results:\n") fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(results, " ")) } // Get backup details using NonAdminDownloadRequest for BackupResourceList - if resourceList, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResourceList"); err == nil { + if resourceList, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: veleroBackupName, + DataType: "BackupResourceList", + Namespace: userNamespace, + }); err == nil { fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Resource List:\n") fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(resourceList, " ")) } // Get backup volume info using NonAdminDownloadRequest - if volumeInfo, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupVolumeInfos"); err == nil { + if volumeInfo, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: veleroBackupName, + DataType: "BackupVolumeInfos", + Namespace: userNamespace, + }); err == nil { fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Volume Info:\n") fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(volumeInfo, " ")) } // Get backup item operations using NonAdminDownloadRequest - if itemOps, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupItemOperations"); err == nil { + if itemOps, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: veleroBackupName, + DataType: "BackupItemOperations", + Namespace: userNamespace, + }); err == nil { fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Item Operations:\n") fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(itemOps, " ")) } @@ -293,104 +489,6 @@ func NonAdminDescribeBackup(cmd *cobra.Command, kbClient kbclient.Client, nab *n return nil } -// downloadBackupData uses NonAdminDownloadRequest to fetch detailed backup information -// This replaces direct access to Velero backups with RBAC-compliant requests -func downloadBackupData(ctx context.Context, kbClient kbclient.Client, userNamespace, backupName, dataType string) (string, error) { - // Create NonAdminDownloadRequest for the specified data type - req := &nacv1alpha1.NonAdminDownloadRequest{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: backupName + "-" + strings.ToLower(dataType) + "-", - Namespace: userNamespace, - }, - Spec: nacv1alpha1.NonAdminDownloadRequestSpec{ - Target: velerov1.DownloadTarget{ - Kind: velerov1.DownloadTargetKind(dataType), - Name: backupName, - }, - }, - } - - if err := kbClient.Create(ctx, req); err != nil { - return "", fmt.Errorf("failed to create NonAdminDownloadRequest for %s: %w", dataType, err) - } - - // Clean up the download request when done - defer func() { - deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelDelete() - _ = kbClient.Delete(deleteCtx, req) - }() - - // Wait for the download request to be processed - timeout := time.After(10 * time.Second) // Reduced timeout since most failures are quick - tick := time.Tick(1 * time.Second) - - for { - select { - case <-timeout: - return "", fmt.Errorf("timed out waiting for %s download request to be processed", dataType) - case <-tick: - var updated nacv1alpha1.NonAdminDownloadRequest - if err := kbClient.Get(ctx, kbclient.ObjectKey{ - Namespace: req.Namespace, - Name: req.Name, - }, &updated); err != nil { - return "", fmt.Errorf("failed to get NonAdminDownloadRequest: %w", err) - } - - // Check if the download request was processed successfully - for _, condition := range updated.Status.Conditions { - if condition.Type == "Processed" && condition.Status == "True" { - if updated.Status.VeleroDownloadRequest.Status.DownloadURL != "" { - // Download and return the content - return downloadContent(updated.Status.VeleroDownloadRequest.Status.DownloadURL) - } - } - } - - // Check for failure conditions - for _, condition := range updated.Status.Conditions { - if condition.Status == "True" && condition.Reason == "Error" { - return "", fmt.Errorf("NonAdminDownloadRequest failed for %s: %s - %s", dataType, condition.Type, condition.Message) - } - } - } - } -} - -// downloadContent fetches content from a signed URL and returns it as a string -func downloadContent(url string) (string, error) { - resp, err := http.Get(url) - if err != nil { - return "", fmt.Errorf("failed to download content from URL %q: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("failed to download content: status %s, body: %s", resp.Status, string(bodyBytes)) - } - - // Try to decompress if it's gzipped - var reader io.Reader = resp.Body - if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { - gzr, err := gzip.NewReader(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzr.Close() - reader = gzr - } - - // Read all content - content, err := io.ReadAll(reader) - if err != nil { - return "", fmt.Errorf("failed to read content: %w", err) - } - - return string(content), nil -} - // Helper to filter out includednamespaces from YAML output func filterIncludedNamespaces(yamlContent string) string { lines := strings.Split(yamlContent, "\n") diff --git a/cmd/non-admin/backup/get.go b/cmd/non-admin/backup/get.go index 67ac40e..a1118b8 100644 --- a/cmd/non-admin/backup/get.go +++ b/cmd/non-admin/backup/get.go @@ -112,15 +112,17 @@ func printNonAdminBackupTable(nabList *nacv1alpha1.NonAdminBackupList) error { } // Print header - fmt.Printf("%-30s %-15s %-20s %-10s\n", "NAME", "STATUS", "CREATED", "AGE") + fmt.Printf("%-30s %-15s %-15s %-20s %-10s %-10s\n", "NAME", "REQUEST PHASE", "VELERO PHASE", "CREATED", "AGE", "DURATION") // Print each backup for _, nab := range nabList.Items { status := getBackupStatus(&nab) + veleroPhase := getVeleroPhase(&nab) created := nab.CreationTimestamp.Format("2006-01-02 15:04:05") age := formatAge(nab.CreationTimestamp.Time) + duration := getBackupDuration(&nab) - fmt.Printf("%-30s %-15s %-20s %-10s\n", nab.Name, status, created, age) + fmt.Printf("%-30s %-15s %-15s %-20s %-10s %-10s\n", nab.Name, status, veleroPhase, created, age, duration) } return nil @@ -133,6 +135,39 @@ func getBackupStatus(nab *nacv1alpha1.NonAdminBackup) string { return "Unknown" } +func getVeleroPhase(nab *nacv1alpha1.NonAdminBackup) string { + if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Status != nil { + if nab.Status.VeleroBackup.Status.Phase != "" { + return string(nab.Status.VeleroBackup.Status.Phase) + } + } + return "N/A" +} + +func getBackupDuration(nab *nacv1alpha1.NonAdminBackup) string { + // Check if we have completion timestamp + if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Status != nil { + if !nab.Status.VeleroBackup.Status.CompletionTimestamp.IsZero() { + // Calculate duration from request creation to completion + duration := nab.Status.VeleroBackup.Status.CompletionTimestamp.Time.Sub(nab.CreationTimestamp.Time) + return formatDuration(duration) + } + } + return "N/A" +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } else if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } else { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, minutes) + } +} + func formatAge(t time.Time) string { duration := time.Since(t) diff --git a/cmd/non-admin/backup/logs.go b/cmd/non-admin/backup/logs.go index a3ca02a..ee8f555 100644 --- a/cmd/non-admin/backup/logs.go +++ b/cmd/non-admin/backup/logs.go @@ -17,12 +17,8 @@ limitations under the License. */ import ( - "bufio" - "compress/gzip" "context" "fmt" - "io" - "net/http" "time" "github.com/migtools/oadp-cli/cmd/shared" @@ -94,17 +90,21 @@ func NewLogsCommand(f client.Factory, use string) *cobra.Command { return fmt.Errorf("failed to create NonAdminDownloadRequest: %w", err) } + // Clean up the download request when done defer func() { deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) defer cancelDelete() _ = kbClient.Delete(deleteCtx, req) }() - var signedURL string - timeout := time.After(120 * time.Second) // Increased timeout to 2 minutes - tick := time.Tick(2 * time.Second) // Check every 2 seconds instead of 1 + fmt.Fprintf(cmd.OutOrStdout(), "Waiting for backup logs to be processed...\n") + + // Wait for the download request to be processed using shared utility + // Note: We create a custom waiting implementation here to provide user feedback + timeout := time.After(120 * time.Second) + tick := time.Tick(2 * time.Second) - fmt.Fprintf(cmd.OutOrStdout(), "Waiting for backup logs to be processed...") + var signedURL string Loop: for { select { @@ -140,29 +140,9 @@ func NewLogsCommand(f client.Factory, use string) *cobra.Command { } } - resp, err := http.Get(signedURL) - if err != nil { - return fmt.Errorf("failed to download logs from URL %q: %w", signedURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to download logs: status %s, body: %s", resp.Status, string(bodyBytes)) - } - - gzr, err := gzip.NewReader(resp.Body) - if err != nil { - return fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzr.Close() - - scanner := bufio.NewScanner(gzr) - for scanner.Scan() { - fmt.Fprintln(cmd.OutOrStdout(), scanner.Text()) - } - if err := scanner.Err(); err != nil && err != io.EOF { - return fmt.Errorf("failed to read logs: %w", err) + // Use the shared StreamDownloadContent function to download and stream logs + if err := shared.StreamDownloadContent(signedURL, cmd.OutOrStdout()); err != nil { + return fmt.Errorf("failed to download and stream logs: %w", err) } return nil diff --git a/cmd/shared/download.go b/cmd/shared/download.go new file mode 100644 index 0000000..22a1c8c --- /dev/null +++ b/cmd/shared/download.go @@ -0,0 +1,200 @@ +/* +Copyright 2025 The OADP CLI 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 shared + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DownloadRequestOptions holds configuration for creating and processing NonAdminDownloadRequests +type DownloadRequestOptions struct { + // BackupName is the name of the backup to download data for + BackupName string + // DataType is the type of data to download (e.g., "BackupLog", "BackupResults", etc.) + DataType velerov1.DownloadTargetKind + // Namespace is the namespace where the download request will be created + Namespace string + // Timeout is the maximum time to wait for the download request to be processed + Timeout time.Duration + // PollInterval is how often to check the status of the download request + PollInterval time.Duration +} + +// ProcessDownloadRequest creates a NonAdminDownloadRequest, waits for it to be processed, +// downloads the content from the signed URL, and returns it as a string. +// This function automatically cleans up the download request when done. +func ProcessDownloadRequest(ctx context.Context, kbClient kbclient.Client, opts DownloadRequestOptions) (string, error) { + // Set defaults + if opts.Timeout == 0 { + opts.Timeout = 120 * time.Second + } + if opts.PollInterval == 0 { + opts.PollInterval = 2 * time.Second + } + + // Create NonAdminDownloadRequest + req := &nacv1alpha1.NonAdminDownloadRequest{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: opts.BackupName + "-" + strings.ToLower(string(opts.DataType)) + "-", + Namespace: opts.Namespace, + }, + Spec: nacv1alpha1.NonAdminDownloadRequestSpec{ + Target: velerov1.DownloadTarget{ + Kind: opts.DataType, + Name: opts.BackupName, + }, + }, + } + + if err := kbClient.Create(ctx, req); err != nil { + return "", fmt.Errorf("failed to create NonAdminDownloadRequest for %s: %w", opts.DataType, err) + } + + // Clean up the download request when done + defer func() { + deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelDelete() + _ = kbClient.Delete(deleteCtx, req) + }() + + // Wait for the download request to be processed + signedURL, err := waitForDownloadURL(ctx, kbClient, req, opts.Timeout, opts.PollInterval) + if err != nil { + return "", err + } + + // Download and return the content + return DownloadContent(signedURL) +} + +// waitForDownloadURL waits for a NonAdminDownloadRequest to be processed and returns the signed URL +func waitForDownloadURL(ctx context.Context, kbClient kbclient.Client, req *nacv1alpha1.NonAdminDownloadRequest, timeout, pollInterval time.Duration) (string, error) { + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-timeoutCtx.Done(): + return "", fmt.Errorf("timed out waiting for NonAdminDownloadRequest to be processed") + case <-ticker.C: + var updated nacv1alpha1.NonAdminDownloadRequest + if err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: req.Namespace, + Name: req.Name, + }, &updated); err != nil { + return "", fmt.Errorf("failed to get NonAdminDownloadRequest: %w", err) + } + + // Check if the download request was processed successfully + for _, condition := range updated.Status.Conditions { + if condition.Type == "Processed" && condition.Status == "True" { + if updated.Status.VeleroDownloadRequest.Status.DownloadURL != "" { + return updated.Status.VeleroDownloadRequest.Status.DownloadURL, nil + } + } + } + + // Check for failure conditions + for _, condition := range updated.Status.Conditions { + if condition.Status == "True" && condition.Reason == "Error" { + return "", fmt.Errorf("NonAdminDownloadRequest failed: %s - %s", condition.Type, condition.Message) + } + } + } + } +} + +// DownloadContent fetches content from a signed URL and returns it as a string. +// It handles both gzipped and non-gzipped content automatically. +func DownloadContent(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to download content from URL %q: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to download content: status %s, body: %s", resp.Status, string(bodyBytes)) + } + + // Try to decompress if it's gzipped + var reader io.Reader = resp.Body + if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + reader = gzr + } + + // Read all content + content, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read content: %w", err) + } + + return string(content), nil +} + +// StreamDownloadContent fetches content from a signed URL and streams it to the provided writer. +// This is useful for large files like logs that should be streamed rather than loaded into memory. +func StreamDownloadContent(url string, writer io.Writer) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download content from URL %q: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to download content: status %s, body: %s", resp.Status, string(bodyBytes)) + } + + // Try to decompress if it's gzipped + var reader io.Reader = resp.Body + if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + reader = gzr + } + + // Stream content to writer + if _, err := io.Copy(writer, reader); err != nil { + return fmt.Errorf("failed to stream content: %w", err) + } + + return nil +}