diff --git a/cmd/non-admin/backup/describe.go b/cmd/non-admin/backup/describe.go index fa90109..5626aa7 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" @@ -16,13 +13,17 @@ import ( 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" ) +// DescribeOptions holds options for the describe command +type DescribeOptions struct { + Details bool +} + func NewDescribeCommand(f client.Factory, use string) *cobra.Command { + options := &DescribeOptions{} + c := &cobra.Command{ Use: use + " NAME", Short: "Describe a non-admin backup", @@ -46,7 +47,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { return err } - // Shows NonAdminBackup resources + // Find the NonAdminBackup var nabList nacv1alpha1.NonAdminBackupList if err := kbClient.List(context.Background(), &nabList, &kbclient.ListOptions{ Namespace: userNamespace, @@ -54,390 +55,442 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { return fmt.Errorf("failed to list NonAdminBackup: %w", err) } - // Find the specific backup - var targetBackup *nacv1alpha1.NonAdminBackup + var foundNAB *nacv1alpha1.NonAdminBackup for i := range nabList.Items { if nabList.Items[i].Name == backupName { - targetBackup = &nabList.Items[i] + foundNAB = &nabList.Items[i] break } } - if targetBackup == nil { + if foundNAB == nil { return fmt.Errorf("NonAdminBackup %q not found in namespace %q", backupName, userNamespace) } - // 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, ",")) - } else { - fmt.Printf("Labels:\t\n") - } - - // 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, ",")) - } else { - fmt.Printf("Annotations:\t\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") - } - } - - // 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)) - } - } - } - - // 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) - } - } - } - } - - return nil + return NonAdminDescribeBackup(cmd, kbClient, foundNAB, userNamespace, options) }, - Example: ` kubectl oadp nonadmin backup describe my-backup`, + Example: ` # Describe a non-admin backup (concise summary) + kubectl oadp nonadmin backup describe my-backup + + # Describe with complete detailed output (same as Velero) + kubectl oadp nonadmin backup describe my-backup --details`, } + // Add the --details flag + c.Flags().BoolVar(&options.Details, "details", false, "Show complete detailed output (same as Velero backup describe --details)") + output.BindFlags(c.Flags()) output.ClearOutputFlagDefault(c) return c } -// NonAdminDescribeBackup mirrors Velero's output.DescribeBackup functionality +// NonAdminDescribeBackup provides a Velero-style detailed output format // but works within non-admin RBAC boundaries using NonAdminDownloadRequest -func NonAdminDescribeBackup(cmd *cobra.Command, kbClient kbclient.Client, nab *nacv1alpha1.NonAdminBackup, userNamespace string) error { +func NonAdminDescribeBackup(cmd *cobra.Command, kbClient kbclient.Client, nab *nacv1alpha1.NonAdminBackup, userNamespace string, options *DescribeOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() - // Print basic backup information + if options.Details { + // Show the full Velero-style detailed output using filtering approach + return NonAdminDescribeBackupDetailed(cmd, kbClient, nab, userNamespace, ctx) + } else { + // Show a concise summary + return NonAdminDescribeBackupSummary(cmd, kbClient, nab, userNamespace, ctx) + } +} + +// NonAdminDescribeBackupSummary provides a concise backup summary +func NonAdminDescribeBackupSummary(cmd *cobra.Command, kbClient kbclient.Client, nab *nacv1alpha1.NonAdminBackup, _ string, ctx context.Context) error { + _ = ctx // Context not currently used but kept for future use + + // Header fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", nab.Name) fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", nab.Namespace) + fmt.Fprintf(cmd.OutOrStdout(), "Phase: %s\n", nab.Status.Phase) - // Print labels - fmt.Fprintf(cmd.OutOrStdout(), "Labels:\n") - if len(nab.Labels) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), " \n") - } else { - labelKeys := make([]string, 0, len(nab.Labels)) - for k := range nab.Labels { - labelKeys = append(labelKeys, k) + // Basic timing if available + if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Status != nil { + vStatus := nab.Status.VeleroBackup.Status + if vStatus.StartTimestamp != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Started: %s\n", vStatus.StartTimestamp.Format("2006-01-02 15:04:05 -0700 MST")) } - sort.Strings(labelKeys) - for _, k := range labelKeys { - fmt.Fprintf(cmd.OutOrStdout(), " %s=%s\n", k, nab.Labels[k]) + if vStatus.CompletionTimestamp != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Completed: %s\n", vStatus.CompletionTimestamp.Format("2006-01-02 15:04:05 -0700 MST")) } - } - - // Print annotations - fmt.Fprintf(cmd.OutOrStdout(), "Annotations:\n") - if len(nab.Annotations) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), " \n") - } else { - annotationKeys := make([]string, 0, len(nab.Annotations)) - for k := range nab.Annotations { - annotationKeys = append(annotationKeys, k) + if vStatus.Progress != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Items backed up: %d\n", vStatus.Progress.ItemsBackedUp) + } + if vStatus.Errors > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Errors: %d\n", vStatus.Errors) } - sort.Strings(annotationKeys) - for _, k := range annotationKeys { - fmt.Fprintf(cmd.OutOrStdout(), " %s=%s\n", k, nab.Annotations[k]) + if vStatus.Warnings > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Warnings: %d\n", vStatus.Warnings) } } - // Print timestamps and status from NonAdminBackup - fmt.Fprintf(cmd.OutOrStdout(), "Creation Timestamp: %s\n", nab.CreationTimestamp.Format(time.RFC3339)) - fmt.Fprintf(cmd.OutOrStdout(), "Phase: %s\n", nab.Status.Phase) + return nil +} - // If there's a referenced Velero backup, get more details - if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Name != "" { - veleroBackupName := nab.Status.VeleroBackup.Name +// NonAdminDescribeBackupDetailed leverages Velero's native describe logic with filtering for non-admin users +func NonAdminDescribeBackupDetailed(cmd *cobra.Command, kbClient kbclient.Client, nab *nacv1alpha1.NonAdminBackup, _ string, ctx context.Context) error { + // Step 1: Get the underlying Velero Backup + veleroBackup, err := getVeleroBackupFromNAB(nab, kbClient, ctx) + if err != nil { + return fmt.Errorf("failed to get Velero backup: %w", err) + } - // Try to get additional backup details, but don't block if they're not available - fmt.Fprintf(cmd.OutOrStdout(), "\nFetching additional backup details...") + // Step 2: Create our own detailed output by filtering Velero-style information + filteredOutput := createFilteredVeleroOutput(veleroBackup, nab) - // Get backup results using NonAdminDownloadRequest (most important data) - if results, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResults"); err == nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Results:\n") - fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(results, " ")) - } + // Step 3: Present the refined output + fmt.Fprint(cmd.OutOrStdout(), filteredOutput) + return nil +} - // Get backup details using NonAdminDownloadRequest for BackupResourceList - if resourceList, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResourceList"); err == nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Resource List:\n") - fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(resourceList, " ")) - } +// getVeleroBackupFromNAB retrieves the underlying Velero Backup from NonAdminBackup +func getVeleroBackupFromNAB(nab *nacv1alpha1.NonAdminBackup, kbClient kbclient.Client, ctx context.Context) (*velerov1.Backup, error) { + if nab.Status.VeleroBackup == nil || nab.Status.VeleroBackup.Name == "" { + return nil, fmt.Errorf("no Velero backup associated with NonAdminBackup %s", nab.Name) + } + + veleroBackupName := nab.Status.VeleroBackup.Name + veleroNamespace := nab.Status.VeleroBackup.Namespace + if veleroNamespace == "" { + veleroNamespace = "openshift-adp" // Default OADP namespace + } + + var veleroBackup velerov1.Backup + err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: veleroNamespace, + Name: veleroBackupName, + }, &veleroBackup) + + if err != nil { + return nil, fmt.Errorf("failed to get Velero backup %s/%s: %w", veleroNamespace, veleroBackupName, err) + } + + return &veleroBackup, nil +} + +// createFilteredVeleroOutput creates a Velero-style output with non-admin field restrictions applied +func createFilteredVeleroOutput(veleroBackup *velerov1.Backup, nab *nacv1alpha1.NonAdminBackup) string { + var output strings.Builder + + // Build output sections + writeBasicInfo(&output, nab) + writeMetadata(&output, nab) + writePhaseAndErrors(&output, nab) + writeAdminEnforceableFields(&output, veleroBackup, nab) + writeStatusInformation(&output, nab) + + return output.String() +} + +// writeBasicInfo writes the basic name and namespace information +func writeBasicInfo(output *strings.Builder, nab *nacv1alpha1.NonAdminBackup) { + output.WriteString(fmt.Sprintf("Name: %s\n", nab.Name)) + output.WriteString(fmt.Sprintf("Namespace: %s\n", nab.Namespace)) +} + +// writeMetadata writes labels and annotations in Velero-style format +func writeMetadata(output *strings.Builder, nab *nacv1alpha1.NonAdminBackup) { + writeKeyValuePairs(output, "Labels", nab.Labels) + writeKeyValuePairs(output, "Annotations", nab.Annotations) + output.WriteString("\n") +} + +// writeKeyValuePairs formats and writes key-value pairs (labels or annotations) +func writeKeyValuePairs(output *strings.Builder, fieldName string, pairs map[string]string) { + if len(pairs) > 0 { + output.WriteString(fmt.Sprintf("%-13s ", fieldName+":")) - // Get backup volume info using NonAdminDownloadRequest - if volumeInfo, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupVolumeInfos"); err == nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Volume Info:\n") - fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(volumeInfo, " ")) + pairStrings := make([]string, 0, len(pairs)) + for k, v := range pairs { + pairStrings = append(pairStrings, fmt.Sprintf("%s=%s", k, v)) } + sort.Strings(pairStrings) + output.WriteString(fmt.Sprintf("%s\n", strings.Join(pairStrings, ","))) + } else { + output.WriteString(fmt.Sprintf("%-13s \n", fieldName+":")) + } +} - // Get backup item operations using NonAdminDownloadRequest - if itemOps, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupItemOperations"); err == nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Item Operations:\n") - fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(itemOps, " ")) +// writePhaseAndErrors writes phase and error/warning information +func writePhaseAndErrors(output *strings.Builder, nab *nacv1alpha1.NonAdminBackup) { + output.WriteString(fmt.Sprintf("Phase: %s\n", nab.Status.Phase)) + + if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Status != nil { + vStatus := nab.Status.VeleroBackup.Status + if vStatus.Errors > 0 { + output.WriteString(fmt.Sprintf("Errors: %d\n", vStatus.Errors)) + } + if vStatus.Warnings > 0 { + output.WriteString(fmt.Sprintf("Warnings: %d\n", vStatus.Warnings)) } + } + +} + +// writeAdminEnforceableFields writes all admin enforceable fields +func writeAdminEnforceableFields(output *strings.Builder, veleroBackup *velerov1.Backup, nab *nacv1alpha1.NonAdminBackup) { + writeTimeoutFields(output, veleroBackup) + writeResourcePolicyFields(output, veleroBackup) + writeNamespaceFields(output, veleroBackup) + writeResourceFields(output, veleroBackup, nab) + writeClusterResourceFields(output, veleroBackup) + writeSelectorFields(output, nab) + writeVolumeFields(output, veleroBackup) + writeStorageFields(output, veleroBackup) + writeBackupPolicyFields(output, veleroBackup) + writeUploaderConfigFields(output, veleroBackup) + writeHookFields(output, veleroBackup) +} - fmt.Fprintf(cmd.OutOrStdout(), "\nDone fetching additional details.") +// writeTimeoutFields writes timeout-related fields +func writeTimeoutFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if veleroBackup.Spec.CSISnapshotTimeout.Duration > 0 { + output.WriteString(fmt.Sprintf("CSI Snapshot Timeout: %s\n", veleroBackup.Spec.CSISnapshotTimeout.Duration.String())) + } + if veleroBackup.Spec.ItemOperationTimeout.Duration > 0 { + output.WriteString(fmt.Sprintf("Item Operation Timeout: %s\n", veleroBackup.Spec.ItemOperationTimeout.Duration.String())) } +} - // Print NonAdminBackup Spec (excluding sensitive information) - if nab.Spec.BackupSpec != nil { - specYaml, err := yaml.Marshal(nab.Spec.BackupSpec) - if err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nSpec: \n", err) - } else { - filteredSpec := filterIncludedNamespaces(string(specYaml)) - fmt.Fprintf(cmd.OutOrStdout(), "\nSpec:\n%s", indent(filteredSpec, " ")) - } +// writeResourcePolicyFields writes resource policy fields +func writeResourcePolicyFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if veleroBackup.Spec.ResourcePolicy != nil { + output.WriteString(fmt.Sprintf("Resource Policy: %s\n", veleroBackup.Spec.ResourcePolicy.Name)) } +} - // Print NonAdminBackup Status (excluding sensitive information) - statusYaml, err := yaml.Marshal(nab.Status) - if err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nStatus: \n", err) +// writeNamespaceFields writes namespace-related fields +func writeNamespaceFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if len(veleroBackup.Spec.ExcludedNamespaces) > 0 { + output.WriteString(fmt.Sprintf("Excluded Namespaces: %s\n", strings.Join(veleroBackup.Spec.ExcludedNamespaces, ", "))) + } +} + +// writeResourceFields writes resource inclusion/exclusion fields +func writeResourceFields(output *strings.Builder, veleroBackup *velerov1.Backup, nab *nacv1alpha1.NonAdminBackup) { + // Included Resources + if nab.Spec.BackupSpec != nil && len(nab.Spec.BackupSpec.IncludedResources) > 0 { + output.WriteString(fmt.Sprintf("Included Resources: %s\n", strings.Join(nab.Spec.BackupSpec.IncludedResources, ", "))) } else { - // Filter out includednamespaces from status output as well - filteredStatus := filterIncludedNamespaces(string(statusYaml)) - fmt.Fprintf(cmd.OutOrStdout(), "\nStatus:\n%s", indent(filteredStatus, " ")) + output.WriteString("Included Resources: * (all)\n") } - // Print Events for NonAdminBackup - fmt.Fprintf(cmd.OutOrStdout(), "\nEvents:\n") - var eventList corev1.EventList - if err := kbClient.List(ctx, &eventList, kbclient.InNamespace(userNamespace)); err != nil { - fmt.Fprintf(cmd.OutOrStdout(), " \n", err) + // Excluded Resources + if nab.Spec.BackupSpec != nil && len(nab.Spec.BackupSpec.ExcludedResources) > 0 { + output.WriteString(fmt.Sprintf("Excluded Resources: %s\n", strings.Join(nab.Spec.BackupSpec.ExcludedResources, ", "))) + } + + // Ordered Resources + if len(veleroBackup.Spec.OrderedResources) > 0 { + output.WriteString("Ordered Resources: configured\n") + } +} + +// writeClusterResourceFields writes cluster resource fields +func writeClusterResourceFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + // Include Cluster Resources + if veleroBackup.Spec.IncludeClusterResources != nil { + output.WriteString(fmt.Sprintf("Include Cluster Resources: %v\n", getBoolPointerValue(veleroBackup.Spec.IncludeClusterResources))) } else { - // Filter events related to this NonAdminBackup - var relatedEvents []corev1.Event - for _, event := range eventList.Items { - if event.InvolvedObject.Kind == "NonAdminBackup" && event.InvolvedObject.Name == nab.Name { - relatedEvents = append(relatedEvents, event) - } - } + output.WriteString("Include Cluster Resources: auto\n") + } - if len(relatedEvents) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), " \n") - } else { - for _, e := range relatedEvents { - fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", e.Reason, e.Message) - } - } + // Excluded Cluster Scoped Resources + if len(veleroBackup.Spec.ExcludedClusterScopedResources) > 0 { + output.WriteString(fmt.Sprintf("Excluded Cluster Scoped Resources: %s\n", strings.Join(veleroBackup.Spec.ExcludedClusterScopedResources, ", "))) } - return nil + // Included Cluster Scoped Resources + if len(veleroBackup.Spec.IncludedClusterScopedResources) > 0 { + output.WriteString(fmt.Sprintf("Included Cluster Scoped Resources: %s\n", strings.Join(veleroBackup.Spec.IncludedClusterScopedResources, ", "))) + } + + // Excluded Namespace Scoped Resources + if len(veleroBackup.Spec.ExcludedNamespaceScopedResources) > 0 { + output.WriteString(fmt.Sprintf("Excluded Namespace Scoped Resources: %s\n", strings.Join(veleroBackup.Spec.ExcludedNamespaceScopedResources, ", "))) + } + + // Included Namespace Scoped Resources + if len(veleroBackup.Spec.IncludedNamespaceScopedResources) > 0 { + output.WriteString(fmt.Sprintf("Included Namespace Scoped Resources: %s\n", strings.Join(veleroBackup.Spec.IncludedNamespaceScopedResources, ", "))) + } } -// 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, - }, - }, +// writeSelectorFields writes label selector fields +func writeSelectorFields(output *strings.Builder, nab *nacv1alpha1.NonAdminBackup) { + if nab.Spec.BackupSpec != nil && nab.Spec.BackupSpec.LabelSelector != nil { + output.WriteString(fmt.Sprintf("Label Selector: %v\n", nab.Spec.BackupSpec.LabelSelector)) + } + if nab.Spec.BackupSpec != nil && len(nab.Spec.BackupSpec.OrLabelSelectors) > 0 { + output.WriteString(fmt.Sprintf("Or Label Selectors: %v\n", nab.Spec.BackupSpec.OrLabelSelectors)) } +} - 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) - } +// writeVolumeFields writes volume-related fields +func writeVolumeFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + output.WriteString(fmt.Sprintf("Snapshot Volumes: %v\n", getSnapshotVolumesValue(veleroBackup.Spec.SnapshotVolumes))) +} - // 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) - } - } - } +// writeStorageFields writes storage-related fields +func writeStorageFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if veleroBackup.Spec.StorageLocation != "" { + output.WriteString(fmt.Sprintf("Storage Location: %s\n", veleroBackup.Spec.StorageLocation)) + } - // 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) - } - } - } + if len(veleroBackup.Spec.VolumeSnapshotLocations) > 0 { + output.WriteString(fmt.Sprintf("Volume Snapshot Locations: %s\n", strings.Join(veleroBackup.Spec.VolumeSnapshotLocations, ", "))) + } else { + output.WriteString("Volume Snapshot Locations: default\n") } } -// 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) +// writeBackupPolicyFields writes backup policy fields +func writeBackupPolicyFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if veleroBackup.Spec.TTL.Duration > 0 { + output.WriteString(fmt.Sprintf("TTL: %s\n", veleroBackup.Spec.TTL.Duration.String())) } - 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)) + if veleroBackup.Spec.DefaultVolumesToFsBackup != nil { + output.WriteString(fmt.Sprintf("Default Volumes to FS Backup: %v\n", getBoolPointerValue(veleroBackup.Spec.DefaultVolumesToFsBackup))) } - // 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) + if veleroBackup.Spec.SnapshotMoveData != nil { + output.WriteString(fmt.Sprintf("Snapshot Move Data: %v\n", getBoolPointerValue(veleroBackup.Spec.SnapshotMoveData))) + } + + if veleroBackup.Spec.DataMover != "" { + output.WriteString(fmt.Sprintf("Data Mover: %s\n", veleroBackup.Spec.DataMover)) + } +} + +// writeUploaderConfigFields writes uploader configuration fields +func writeUploaderConfigFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if veleroBackup.Spec.UploaderConfig != nil { + output.WriteString("Uploader Config: configured\n") + if veleroBackup.Spec.UploaderConfig.ParallelFilesUpload > 0 { + output.WriteString(fmt.Sprintf(" Parallel Files Upload: %d\n", veleroBackup.Spec.UploaderConfig.ParallelFilesUpload)) } - 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) +// writeHookFields writes hook-related fields +func writeHookFields(output *strings.Builder, veleroBackup *velerov1.Backup) { + if len(veleroBackup.Spec.Hooks.Resources) > 0 { + output.WriteString(fmt.Sprintf("Hooks: %d hook(s) configured\n", len(veleroBackup.Spec.Hooks.Resources))) + } +} + +// writeStatusInformation writes the status information section +func writeStatusInformation(output *strings.Builder, nab *nacv1alpha1.NonAdminBackup) { + if nab.Status.VeleroBackup == nil || nab.Status.VeleroBackup.Status == nil { + return } - return string(content), nil + vStatus := nab.Status.VeleroBackup.Status + output.WriteString("=== STATUS INFORMATION ===\n") + + writeFormatAndTimestamps(output, vStatus) + writeProgressInformation(output, vStatus) + writeResourceList(output, vStatus) + writeVolumeInformation(output, vStatus, nab) + writeHookStatus(output, vStatus) } -// Helper to filter out includednamespaces from YAML output -func filterIncludedNamespaces(yamlContent string) string { - lines := strings.Split(yamlContent, "\n") - var filtered []string - skip := false - var skipIndentLevel int - - for i := 0; i < len(lines); i++ { - line := lines[i] - trimmed := strings.TrimSpace(line) - - // Calculate indentation level - indentLevel := len(line) - len(strings.TrimLeft(line, " \t")) - - // Check if this line starts the includednamespaces field - if !skip && (trimmed == "includednamespaces:" || trimmed == "includedNamespaces:" || - strings.HasPrefix(trimmed, "includednamespaces: ") || strings.HasPrefix(trimmed, "includedNamespaces: ")) { - skip = true - skipIndentLevel = indentLevel - continue - } +// writeFormatAndTimestamps writes backup format version and timestamps +func writeFormatAndTimestamps(output *strings.Builder, vStatus *velerov1.BackupStatus) { + if vStatus.FormatVersion != "" { + output.WriteString(fmt.Sprintf("Backup Format Version: %s\n", vStatus.FormatVersion)) + } - if skip { - // Stop skipping if we found a line at the same or lesser indentation level - // and it's not an empty line and it's not a list item belonging to the skipped field - if trimmed != "" && indentLevel <= skipIndentLevel && !strings.HasPrefix(trimmed, "- ") { - skip = false - // Process this line since we're no longer skipping - filtered = append(filtered, line) - } - // If we're still skipping, don't add the line - continue - } + if vStatus.StartTimestamp != nil { + output.WriteString(fmt.Sprintf("Started: %s\n", vStatus.StartTimestamp.Format("2006-01-02 15:04:05 -0700 MST"))) + } + if vStatus.CompletionTimestamp != nil { + output.WriteString(fmt.Sprintf("Completed: %s\n", vStatus.CompletionTimestamp.Format("2006-01-02 15:04:05 -0700 MST"))) + } + if vStatus.Expiration != nil { + output.WriteString(fmt.Sprintf("Expiration: %s\n", vStatus.Expiration.Format("2006-01-02 15:04:05 -0700 MST"))) + } + + output.WriteString("\n") +} - // Add the line if we're not skipping - filtered = append(filtered, line) +// writeProgressInformation writes backup progress information +func writeProgressInformation(output *strings.Builder, vStatus *velerov1.BackupStatus) { + if vStatus.Progress != nil { + output.WriteString(fmt.Sprintf("Total items to be backed up: %d\n", vStatus.Progress.TotalItems)) + output.WriteString(fmt.Sprintf("Items backed up: %d\n", vStatus.Progress.ItemsBackedUp)) } - return strings.Join(filtered, "\n") + output.WriteString("\n") } -// Helper to indent YAML blocks -func indent(s, prefix string) string { - lines := strings.Split(s, "\n") - for i, line := range lines { - if len(line) > 0 { - lines[i] = prefix + line - } +// writeResourceList writes the resource list information +func writeResourceList(output *strings.Builder, vStatus *velerov1.BackupStatus) { + output.WriteString("Resource List:\n") + if vStatus.Progress != nil { + output.WriteString(fmt.Sprintf(" Total items backed up: %d\n", vStatus.Progress.ItemsBackedUp)) + } else { + output.WriteString(" \n") + } + output.WriteString("\n") +} + +// writeVolumeInformation writes backup volume information +func writeVolumeInformation(output *strings.Builder, vStatus *velerov1.BackupStatus, nab *nacv1alpha1.NonAdminBackup) { + output.WriteString("Backup Volumes:\n") + + // Velero-Native Snapshots + if vStatus.VolumeSnapshotsCompleted > 0 { + output.WriteString(fmt.Sprintf(" Velero-Native Snapshots: <%d included>\n", vStatus.VolumeSnapshotsCompleted)) + } else { + output.WriteString(" Velero-Native Snapshots: \n") + } + + // CSI Snapshots + if vStatus.CSIVolumeSnapshotsCompleted > 0 { + output.WriteString(fmt.Sprintf(" CSI Snapshots: <%d included>\n", vStatus.CSIVolumeSnapshotsCompleted)) + } else { + output.WriteString(" CSI Snapshots: \n") + } + + // Pod Volume Backups + if nab.Status.FileSystemPodVolumeBackups != nil && nab.Status.FileSystemPodVolumeBackups.Completed > 0 { + output.WriteString(fmt.Sprintf(" Pod Volume Backups: <%d included>\n", nab.Status.FileSystemPodVolumeBackups.Completed)) + } else { + output.WriteString(" Pod Volume Backups: \n") + } + + output.WriteString("\n") +} + +// writeHookStatus writes hook status information +func writeHookStatus(output *strings.Builder, vStatus *velerov1.BackupStatus) { + output.WriteString(fmt.Sprintf("Hooks Attempted: %d\n", vStatus.HookStatus.HooksAttempted)) + output.WriteString(fmt.Sprintf("Hooks Failed: %d\n", vStatus.HookStatus.HooksFailed)) +} + +// Helper functions for the detailed output +func getSnapshotVolumesValue(snapshots *bool) string { + if snapshots == nil { + return "auto" + } + if *snapshots { + return "true" + } + return "false" +} + +func getBoolPointerValue(b *bool) string { + if b == nil { + return "auto" + } + if *b { + return "true" } - return strings.Join(lines, "\n") + return "false" } diff --git a/cmd/non-admin/nonadmin.go b/cmd/non-admin/nonadmin.go index 38dc65e..34329df 100644 --- a/cmd/non-admin/nonadmin.go +++ b/cmd/non-admin/nonadmin.go @@ -19,6 +19,7 @@ package nonadmin import ( "github.com/migtools/oadp-cli/cmd/non-admin/backup" "github.com/migtools/oadp-cli/cmd/non-admin/bsl" + "github.com/migtools/oadp-cli/cmd/non-admin/restore" "github.com/spf13/cobra" "github.com/vmware-tanzu/velero/pkg/client" ) @@ -28,13 +29,15 @@ func NewNonAdminCommand(f client.Factory) *cobra.Command { c := &cobra.Command{ Use: "nonadmin", Short: "Work with non-admin resources", - Long: "Work with non-admin resources like backups and backup storage locations", + Long: "Work with non-admin resources like backups, restores, and backup storage locations", Aliases: []string{"na"}, } // Add backup subcommand c.AddCommand(backup.NewBackupCommand(f)) + // Add restore subcommand + c.AddCommand(restore.NewRestoreCommand(f)) // Add backup storage location subcommand c.AddCommand(bsl.NewBSLCommand(f)) diff --git a/cmd/non-admin/nonadmin_test.go b/cmd/non-admin/nonadmin_test.go index 8883fa1..dede34b 100644 --- a/cmd/non-admin/nonadmin_test.go +++ b/cmd/non-admin/nonadmin_test.go @@ -39,6 +39,7 @@ func TestNonAdminCommands(t *testing.T) { "Work with non-admin resources like backups", "backup", "bsl", + "restore", }, }, { @@ -76,6 +77,8 @@ func TestNonAdminHelpFlags(t *testing.T) { {"nonadmin", "backup", "-h"}, {"nonadmin", "bsl", "--help"}, {"nonadmin", "bsl", "-h"}, + {"nonadmin", "restore", "--help"}, + {"nonadmin", "restore", "-h"}, } for _, cmd := range commands { diff --git a/cmd/non-admin/restore/create.go b/cmd/non-admin/restore/create.go new file mode 100644 index 0000000..4856255 --- /dev/null +++ b/cmd/non-admin/restore/create.go @@ -0,0 +1,295 @@ +package restore + +/* +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. +*/ + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/client" + "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" +) + +func NewCreateCommand(f client.Factory, use string) *cobra.Command { + o := NewCreateOptions() + + c := &cobra.Command{ + Use: use + " NAME --from-backup BACKUP_NAME", + Short: "Create a non-admin restore", + Args: cobra.MaximumNArgs(1), + Run: func(c *cobra.Command, args []string) { + cmd.CheckError(o.Complete(args, f)) + cmd.CheckError(o.Validate(c, args, f)) + cmd.CheckError(o.Run(c, f)) + }, + Example: ` # Create a non-admin restore from a backup in the current namespace. + kubectl oadp nonadmin restore create restore1 --from-backup backup1 + + # Create a non-admin restore with specific resource types. + kubectl oadp nonadmin restore create restore2 --from-backup backup1 --include-resources deployments,services + + # Create a non-admin restore excluding certain resources. + kubectl oadp nonadmin restore create restore3 --from-backup backup1 --exclude-resources secrets + + # View the YAML for a non-admin restore without sending it to the server. + kubectl oadp nonadmin restore create restore4 --from-backup backup1 -o yaml + + # Wait for a non-admin restore to complete before returning from the command. + kubectl oadp nonadmin restore create restore5 --from-backup backup1 --wait`, + } + + o.BindFlags(c.Flags()) + o.BindWait(c.Flags()) + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +type CreateOptions struct { + Name string + FromBackup string + IncludeResources flag.StringArray + ExcludeResources flag.StringArray + Labels flag.Map + Annotations flag.Map + Selector flag.LabelSelector + OrSelector flag.OrLabelSelector + IncludeClusterResources flag.OptionalBool + Wait bool + RestorePVs flag.OptionalBool + PreserveNodePorts flag.OptionalBool + ItemOperationTimeout time.Duration + ExistingResourcePolicy string + UploaderConfig flag.Map + client kbclient.WithWatch + currentNamespace string +} + +func NewCreateOptions() *CreateOptions { + return &CreateOptions{ + IncludeResources: flag.NewStringArray("*"), + Labels: flag.NewMap(), + Annotations: flag.NewMap(), + UploaderConfig: flag.NewMap(), + IncludeClusterResources: flag.NewOptionalBool(nil), + RestorePVs: flag.NewOptionalBool(nil), + PreserveNodePorts: flag.NewOptionalBool(nil), + } +} + +func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.FromBackup, "from-backup", o.FromBackup, "Backup to restore from (required).") + flags.Var(&o.IncludeResources, "include-resources", "Resources to include in the restore, formatted as resource.group, such as storageclasses.storage.k8s.io (use '*' for all resources).") + flags.Var(&o.ExcludeResources, "exclude-resources", "Resources to exclude from the restore, formatted as resource.group, such as storageclasses.storage.k8s.io.") + flags.Var(&o.Labels, "labels", "Labels to apply to the restore.") + flags.Var(&o.Annotations, "annotations", "Annotations to apply to the restore.") + flags.VarP(&o.Selector, "selector", "l", "Only restore resources matching this label selector.") + flags.Var(&o.OrSelector, "or-selector", "Restore resources matching at least one of the label selector from the list. Label selectors should be separated by ' or '. For example, foo=bar or app=nginx") + flags.DurationVar(&o.ItemOperationTimeout, "item-operation-timeout", o.ItemOperationTimeout, "How long to wait for async plugin operations before timeout.") + flags.StringVar(&o.ExistingResourcePolicy, "existing-resource-policy", "", "Policy to handle restore collisions (none, update)") + flags.Var(&o.UploaderConfig, "uploader-config", "Configuration for the uploader in form key1=value1,key2=value2") + + f := flags.VarPF(&o.IncludeClusterResources, "include-cluster-resources", "", "Include cluster-scoped resources.") + f.NoOptDefVal = cmd.TRUE + + f = flags.VarPF(&o.RestorePVs, "restore-volumes", "", "Whether to restore volumes from snapshots.") + f.NoOptDefVal = cmd.TRUE + + f = flags.VarPF(&o.PreserveNodePorts, "preserve-nodeports", "", "Whether to restore NodePort services as NodePort.") + f.NoOptDefVal = cmd.TRUE +} + +func (o *CreateOptions) BindWait(flags *pflag.FlagSet) { + flags.BoolVarP(&o.Wait, "wait", "w", o.Wait, "Wait for the operation to complete.") +} + +func (o *CreateOptions) Complete(args []string, f client.Factory) error { + // If an explicit name is specified, use that name + if len(args) > 0 { + o.Name = args[0] + } + + // Create client with NonAdmin scheme + client, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + }) + if err != nil { + return err + } + + // Get the current namespace from kubeconfig instead of using factory namespace + currentNS, err := shared.GetCurrentNamespace() + if err != nil { + return fmt.Errorf("failed to determine current namespace: %w", err) + } + + o.client = client + o.currentNamespace = currentNS + return nil +} + +func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { + if len(args) < 1 { + return fmt.Errorf("restore name is required") + } + + if o.FromBackup == "" { + return fmt.Errorf("--from-backup is required") + } + + if o.Name == "" { + o.Name = args[0] + } + + return nil +} + +func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { + if printed, err := output.PrintWithFormat(c, o.buildRestore()); printed || err != nil { + return err + } + + restore := o.buildRestore() + + if err := o.client.Create(context.Background(), restore); err != nil { + return err + } + + fmt.Printf("NonAdminRestore %q created successfully.\n", restore.Name) + + if o.Wait { + return o.waitForRestore(restore) + } + + return nil +} + +func (o *CreateOptions) buildRestore() *nacv1alpha1.NonAdminRestore { + // Create a Velero RestoreSpec + restoreSpec := &velerov1api.RestoreSpec{ + BackupName: o.FromBackup, + } + + // Add resource filters + if len(o.IncludeResources) > 0 { + restoreSpec.IncludedResources = o.IncludeResources + } + if len(o.ExcludeResources) > 0 { + restoreSpec.ExcludedResources = o.ExcludeResources + } + + // Note: The namespace-scoped and cluster-scoped resource filters are only available + // in backup operations, not restore operations in Velero RestoreSpec. + // For restores, use IncludedResources/ExcludedResources with specific resource types. + + // Note: Namespace mappings are restricted for non-admin users and therefore not processed + + // Add selectors + if o.Selector.LabelSelector != nil { + restoreSpec.LabelSelector = o.Selector.LabelSelector + } + if len(o.OrSelector.OrLabelSelectors) > 0 { + restoreSpec.OrLabelSelectors = o.OrSelector.OrLabelSelectors + } + + // Add optional settings + if o.IncludeClusterResources.Value != nil { + restoreSpec.IncludeClusterResources = o.IncludeClusterResources.Value + } + if o.RestorePVs.Value != nil { + restoreSpec.RestorePVs = o.RestorePVs.Value + } + if o.PreserveNodePorts.Value != nil { + restoreSpec.PreserveNodePorts = o.PreserveNodePorts.Value + } + if o.ItemOperationTimeout > 0 { + restoreSpec.ItemOperationTimeout = metav1.Duration{Duration: o.ItemOperationTimeout} + } + if o.ExistingResourcePolicy != "" { + policy := velerov1api.PolicyType(o.ExistingResourcePolicy) + restoreSpec.ExistingResourcePolicy = policy + } + if o.UploaderConfig.Data() != nil && len(o.UploaderConfig.Data()) > 0 { + restoreSpec.UploaderConfig = &velerov1api.UploaderConfigForRestore{} + // Note: UploaderConfigForRestore fields would be set here based on the map values + // The exact field structure depends on the Velero version being used + } + + // Create NonAdminRestore using the builder + restore := ForNonAdminRestore(o.currentNamespace, o.Name). + ObjectMeta( + WithLabelsMap(o.Labels.Data()), + WithAnnotationsMap(o.Annotations.Data()), + ). + RestoreSpec(nacv1alpha1.NonAdminRestoreSpec{ + RestoreSpec: restoreSpec, + }). + Result() + + return restore +} + +func (o *CreateOptions) waitForRestore(restore *nacv1alpha1.NonAdminRestore) error { + fmt.Printf("Waiting for restore %s to complete...\n", restore.Name) + + // TODO: Implement proper wait functionality + // For now, just poll the restore status periodically + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for restore to complete") + case <-ticker.C: + // Get current restore status + currentRestore := &nacv1alpha1.NonAdminRestore{} + err := o.client.Get(ctx, kbclient.ObjectKey{ + Namespace: restore.Namespace, + Name: restore.Name, + }, currentRestore) + if err != nil { + return fmt.Errorf("failed to get restore status: %w", err) + } + + phase := currentRestore.Status.Phase + fmt.Printf("Restore %s status: %s\n", restore.Name, phase) + + // Check if completed (using generic NonAdminPhase constants) + if phase == nacv1alpha1.NonAdminPhaseCreated { + fmt.Printf("Restore %s completed successfully.\n", restore.Name) + return nil + } + // Add other phase checks as needed + } + } +} diff --git a/cmd/non-admin/restore/delete.go b/cmd/non-admin/restore/delete.go new file mode 100644 index 0000000..9fa9085 --- /dev/null +++ b/cmd/non-admin/restore/delete.go @@ -0,0 +1,254 @@ +package restore + +/* +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. +*/ + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/api/errors" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" +) + +// NewDeleteCommand creates a cobra command for deleting non-admin restores +func NewDeleteCommand(f client.Factory, use string) *cobra.Command { + o := NewDeleteOptions() + + c := &cobra.Command{ + Use: use + " NAME [NAME...]", + Short: "Delete one or more non-admin restores", + Long: "Delete one or more non-admin restores permanently from the cluster", + Args: cobra.MinimumNArgs(1), + Run: func(c *cobra.Command, args []string) { + cmd.CheckError(o.Complete(args, f)) + cmd.CheckError(o.Validate()) + cmd.CheckError(o.Run()) + }, + } + + o.BindFlags(c.Flags()) + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +// DeleteOptions holds the options for the delete command +type DeleteOptions struct { + Names []string + Namespace string // Internal field - automatically determined from kubectl context + Confirm bool // Skip confirmation prompt + client kbclient.Client +} + +// NewDeleteOptions creates a new DeleteOptions instance +func NewDeleteOptions() *DeleteOptions { + return &DeleteOptions{} +} + +// BindFlags binds the command line flags to the options +func (o *DeleteOptions) BindFlags(flags *pflag.FlagSet) { + flags.BoolVar(&o.Confirm, "confirm", false, "Skip confirmation prompt and delete immediately") +} + +// Complete completes the options by setting up the client and determining the namespace +func (o *DeleteOptions) Complete(args []string, f client.Factory) error { + o.Names = args + + // Create client with NonAdmin scheme + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + }) + if err != nil { + return err + } + + o.client = kbClient + + // Always use the current namespace from kubectl context + currentNS, err := shared.GetCurrentNamespace() + if err != nil { + return fmt.Errorf("failed to determine current namespace: %w", err) + } + o.Namespace = currentNS + + return nil +} + +// Validate validates the options +func (o *DeleteOptions) Validate() error { + if len(o.Names) == 0 { + return fmt.Errorf("at least one restore name is required") + } + if o.Namespace == "" { + return fmt.Errorf("namespace is required") + } + return nil +} + +// Run executes the delete command +func (o *DeleteOptions) Run() error { + // Show what will be deleted + fmt.Printf("The following NonAdminRestore(s) will be permanently deleted from namespace '%s':\n", o.Namespace) + for _, name := range o.Names { + fmt.Printf(" - %s\n", name) + } + fmt.Println() + + // Prompt for confirmation unless --confirm flag is used + if !o.Confirm { + confirmed, err := o.promptForConfirmation() + if err != nil { + return err + } + if !confirmed { + fmt.Println("Deletion cancelled.") + return nil + } + } + + // Track results + var successful []string + var failed []string + + // Process each restore + for _, name := range o.Names { + err := o.deleteRestore(name) + if err != nil { + fmt.Printf("❌ Failed to delete %s: %v\n", name, err) + failed = append(failed, name) + } else { + fmt.Printf("✓ %s deleted\n", name) + successful = append(successful, name) + } + } + + // Print summary + fmt.Println() + if len(successful) > 0 { + fmt.Printf("Successfully deleted %d restore(s):\n", len(successful)) + for _, name := range successful { + fmt.Printf(" - %s\n", name) + } + } + + if len(failed) > 0 { + fmt.Printf("Failed to delete %d restore(s):\n", len(failed)) + for _, name := range failed { + fmt.Printf(" - %s\n", name) + } + return fmt.Errorf("some operations failed") + } + + return nil +} + +// promptForConfirmation prompts the user for confirmation +func (o *DeleteOptions) promptForConfirmation() (bool, error) { + reader := bufio.NewReader(os.Stdin) + + if len(o.Names) == 1 { + fmt.Printf("Are you sure you want to delete restore '%s'? (y/N): ", o.Names[0]) + } else { + fmt.Printf("Are you sure you want to delete these %d restores? (y/N): ", len(o.Names)) + } + + response, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("failed to read user input: %w", err) + } + + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes", nil +} + +// deleteRestore deletes a single restore +func (o *DeleteOptions) deleteRestore(name string) error { + // Get the NonAdminRestore resource + nar := &nacv1alpha1.NonAdminRestore{} + err := o.client.Get(context.TODO(), kbclient.ObjectKey{ + Name: name, + Namespace: o.Namespace, + }, nar) + if err != nil { + return o.translateError(name, err) + } + + // Delete the resource directly + err = o.client.Delete(context.TODO(), nar) + if err != nil { + return o.translateError(name, err) + } + + return nil +} + +// translateError converts verbose Kubernetes errors into user-friendly messages +func (o *DeleteOptions) translateError(name string, err error) error { + if errors.IsNotFound(err) { + return fmt.Errorf("restore '%s' not found", name) + } + + if errors.IsForbidden(err) { + return fmt.Errorf("permission denied") + } + + if errors.IsUnauthorized(err) { + return fmt.Errorf("authentication required") + } + + if errors.IsConflict(err) { + return fmt.Errorf("restore '%s' was modified, please try again", name) + } + + if errors.IsTimeout(err) { + return fmt.Errorf("request timed out") + } + + if errors.IsServerTimeout(err) { + return fmt.Errorf("server timeout") + } + + if errors.IsServiceUnavailable(err) { + return fmt.Errorf("service unavailable") + } + + // Check for common connection issues + errStr := err.Error() + if strings.Contains(errStr, "connection refused") { + return fmt.Errorf("cannot connect to cluster") + } + + if strings.Contains(errStr, "no such host") { + return fmt.Errorf("cannot reach cluster") + } + + // For any other error, provide a generic message + return fmt.Errorf("operation failed") +} diff --git a/cmd/non-admin/restore/describe.go b/cmd/non-admin/restore/describe.go new file mode 100644 index 0000000..69da362 --- /dev/null +++ b/cmd/non-admin/restore/describe.go @@ -0,0 +1,376 @@ +package restore + +/* +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. +*/ + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + 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" +) + +// DescribeOptions holds options for the describe command +type DescribeOptions struct { + Details bool +} + +func NewDescribeCommand(f client.Factory, use string) *cobra.Command { + options := &DescribeOptions{} + + c := &cobra.Command{ + Use: use + " NAME", + Short: "Describe a non-admin restore", + Long: "Display detailed information about a specified non-admin restore", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + restoreName := args[0] + + // Get the current namespace from kubectl context + userNamespace, err := shared.GetCurrentNamespace() + if err != nil { + return fmt.Errorf("failed to determine current namespace: %w", err) + } + + // Create client with required scheme types + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + IncludeVeleroTypes: true, + IncludeCoreTypes: true, + }) + if err != nil { + return err + } + + // Find the NonAdminRestore + var narList nacv1alpha1.NonAdminRestoreList + if err := kbClient.List(context.Background(), &narList, &kbclient.ListOptions{ + Namespace: userNamespace, + }); err != nil { + return fmt.Errorf("failed to list NonAdminRestore: %w", err) + } + + var foundNAR *nacv1alpha1.NonAdminRestore + for i := range narList.Items { + if narList.Items[i].Name == restoreName { + foundNAR = &narList.Items[i] + break + } + } + + if foundNAR == nil { + return fmt.Errorf("NonAdminRestore %q not found in namespace %q", restoreName, userNamespace) + } + + return NonAdminDescribeRestore(cmd, kbClient, foundNAR, userNamespace, options) + }, + Example: ` # Describe a non-admin restore (concise summary) + kubectl oadp nonadmin restore describe my-restore + + # Describe with complete detailed output (same as Velero) + kubectl oadp nonadmin restore describe my-restore --details`, + } + + // Add the --details flag + c.Flags().BoolVar(&options.Details, "details", false, "Show complete detailed output (same as Velero restore describe --details)") + + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +// NonAdminDescribeRestore provides a Velero-style detailed output format +// but works within non-admin RBAC boundaries using NonAdminDownloadRequest +func NonAdminDescribeRestore(cmd *cobra.Command, kbClient kbclient.Client, nar *nacv1alpha1.NonAdminRestore, userNamespace string, options *DescribeOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if options.Details { + // Show the full Velero-style detailed output using filtering approach + return NonAdminDescribeRestoreDetailed(cmd, kbClient, nar, userNamespace, ctx) + } else { + // Show a concise summary + return NonAdminDescribeRestoreSummary(cmd, kbClient, nar, userNamespace, ctx) + } +} + +// NonAdminDescribeRestoreSummary provides a concise restore summary +func NonAdminDescribeRestoreSummary(cmd *cobra.Command, kbClient kbclient.Client, nar *nacv1alpha1.NonAdminRestore, _ string, ctx context.Context) error { + _ = ctx // Context not currently used but kept for future use + + // Header + fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", nar.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", nar.Namespace) + fmt.Fprintf(cmd.OutOrStdout(), "Phase: %s\n", nar.Status.Phase) + + // Basic timing if available + if nar.Status.VeleroRestore != nil && nar.Status.VeleroRestore.Status != nil { + vStatus := nar.Status.VeleroRestore.Status + if vStatus.StartTimestamp != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Started: %s\n", vStatus.StartTimestamp.Format("2006-01-02 15:04:05 -0700 MST")) + } + if vStatus.CompletionTimestamp != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Completed: %s\n", vStatus.CompletionTimestamp.Format("2006-01-02 15:04:05 -0700 MST")) + } + if vStatus.Progress != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Items restored: %d\n", vStatus.Progress.ItemsRestored) + } + if vStatus.Errors > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Errors: %d\n", vStatus.Errors) + } + if vStatus.Warnings > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Warnings: %d\n", vStatus.Warnings) + } + } + + // NOTE: backupName is not admin enforceable (restricted) and therefore not displayed + + return nil +} + +// NonAdminDescribeRestoreDetailed leverages Velero's native restore data with filtering for non-admin users +func NonAdminDescribeRestoreDetailed(cmd *cobra.Command, kbClient kbclient.Client, nar *nacv1alpha1.NonAdminRestore, _ string, ctx context.Context) error { + // Step 1: Get the underlying Velero Restore + veleroRestore, err := getVeleroRestoreFromNAR(nar, kbClient, ctx) + if err != nil { + return fmt.Errorf("failed to get Velero restore: %w", err) + } + + // Step 2: Create our own detailed output by filtering Velero-style information + filteredOutput := createFilteredVeleroRestoreOutput(veleroRestore, nar) + + // Step 3: Present the refined output + fmt.Fprint(cmd.OutOrStdout(), filteredOutput) + return nil +} + +// getVeleroRestoreFromNAR retrieves the underlying Velero Restore from NonAdminRestore +func getVeleroRestoreFromNAR(nar *nacv1alpha1.NonAdminRestore, kbClient kbclient.Client, ctx context.Context) (*velerov1.Restore, error) { + if nar.Status.VeleroRestore == nil || nar.Status.VeleroRestore.Name == "" { + return nil, fmt.Errorf("no Velero restore associated with NonAdminRestore %s", nar.Name) + } + + veleroRestoreName := nar.Status.VeleroRestore.Name + veleroNamespace := nar.Status.VeleroRestore.Namespace + if veleroNamespace == "" { + veleroNamespace = "openshift-adp" // Default OADP namespace + } + + var veleroRestore velerov1.Restore + err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: veleroNamespace, + Name: veleroRestoreName, + }, &veleroRestore) + + if err != nil { + return nil, fmt.Errorf("failed to get Velero restore %s/%s: %w", veleroNamespace, veleroRestoreName, err) + } + + return &veleroRestore, nil +} + +// createFilteredVeleroRestoreOutput creates a Velero-style output with non-admin field restrictions applied +func createFilteredVeleroRestoreOutput(_ *velerov1.Restore, nar *nacv1alpha1.NonAdminRestore) string { + var output strings.Builder + + // Header in Velero style - Admin Enforceable: Yes + output.WriteString(fmt.Sprintf("Name: %s\n", nar.Name)) + output.WriteString(fmt.Sprintf("Namespace: %s\n", nar.Namespace)) + + // Labels (Velero-style format) - Admin Enforceable: Yes + if len(nar.Labels) > 0 { + output.WriteString("Labels: ") + labelPairs := make([]string, 0, len(nar.Labels)) + for k, v := range nar.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(labelPairs) + output.WriteString(fmt.Sprintf("%s\n", strings.Join(labelPairs, ","))) + } else { + output.WriteString("Labels: \n") + } + + // Annotations (Velero-style format) - Admin Enforceable: Yes + if len(nar.Annotations) > 0 { + output.WriteString("Annotations: ") + annotationPairs := make([]string, 0, len(nar.Annotations)) + for k, v := range nar.Annotations { + annotationPairs = append(annotationPairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(annotationPairs) + output.WriteString(fmt.Sprintf("%s\n", strings.Join(annotationPairs, ","))) + } else { + output.WriteString("Annotations: \n") + } + + output.WriteString("\n") + + // Phase/Status information - Admin Enforceable: Yes + output.WriteString(fmt.Sprintf("Phase: %s\n", nar.Status.Phase)) + + // Add error/warning information if available - Admin Enforceable: Yes + if nar.Status.VeleroRestore != nil && nar.Status.VeleroRestore.Status != nil { + vStatus := nar.Status.VeleroRestore.Status + if vStatus.Errors > 0 { + output.WriteString(fmt.Sprintf("Errors: %d\n", vStatus.Errors)) + } + if vStatus.Warnings > 0 { + output.WriteString(fmt.Sprintf("Warnings: %d\n", vStatus.Warnings)) + } + } + + output.WriteString("\n") + + // === ADMIN ENFORCEABLE FIELDS (only show if configured) === + + // ItemOperationTimeout - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.ItemOperationTimeout.Duration > 0 { + output.WriteString(fmt.Sprintf("Item Operation Timeout: %s\n", nar.Spec.RestoreSpec.ItemOperationTimeout.Duration.String())) + } + + // UploaderConfig - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.UploaderConfig != nil { + output.WriteString("Uploader Config: configured\n") + } + + // IncludedResources - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && len(nar.Spec.RestoreSpec.IncludedResources) > 0 { + output.WriteString(fmt.Sprintf("Included Resources: %s\n", strings.Join(nar.Spec.RestoreSpec.IncludedResources, ", "))) + } else { + output.WriteString("Included Resources: * (all)\n") + } + + // ExcludedResources - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && len(nar.Spec.RestoreSpec.ExcludedResources) > 0 { + output.WriteString(fmt.Sprintf("Excluded Resources: %s\n", strings.Join(nar.Spec.RestoreSpec.ExcludedResources, ", "))) + } + + // RestoreStatus - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.RestoreStatus != nil { + output.WriteString("Restore Status: configured\n") + } + + // IncludeClusterResources - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.IncludeClusterResources != nil { + output.WriteString(fmt.Sprintf("Include Cluster Resources: %v\n", *nar.Spec.RestoreSpec.IncludeClusterResources)) + } else { + output.WriteString("Include Cluster Resources: auto\n") + } + + // LabelSelector - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.LabelSelector != nil { + output.WriteString(fmt.Sprintf("Label Selector: %v\n", nar.Spec.RestoreSpec.LabelSelector)) + } + + // OrLabelSelectors - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && len(nar.Spec.RestoreSpec.OrLabelSelectors) > 0 { + output.WriteString(fmt.Sprintf("Or Label Selectors: %v\n", nar.Spec.RestoreSpec.OrLabelSelectors)) + } + + // RestorePVs - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.RestorePVs != nil { + output.WriteString(fmt.Sprintf("Restore PVs: %v\n", *nar.Spec.RestoreSpec.RestorePVs)) + } else { + output.WriteString("Restore PVs: auto\n") + } + + // PreserveNodePorts - Admin Enforceable: Yes + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.PreserveNodePorts != nil { + output.WriteString(fmt.Sprintf("Preserve Node Ports: %v\n", *nar.Spec.RestoreSpec.PreserveNodePorts)) + } + + // ExistingResourcePolicy - Admin Enforceable: (blank/unspecified) + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.ExistingResourcePolicy != "" { + output.WriteString(fmt.Sprintf("Existing Resource Policy: %s\n", nar.Spec.RestoreSpec.ExistingResourcePolicy)) + } + + // Hooks - Admin Enforceable: (blank/unspecified) - special case + if nar.Spec.RestoreSpec != nil && len(nar.Spec.RestoreSpec.Hooks.Resources) > 0 { + output.WriteString(fmt.Sprintf("Hooks: %d hook(s) configured\n", len(nar.Spec.RestoreSpec.Hooks.Resources))) + } + + // ResourceModifiers - Admin Enforceable: (blank/unspecified) - special case (admins can enforce config-map in OADP Operator NS) + if nar.Spec.RestoreSpec != nil && nar.Spec.RestoreSpec.ResourceModifier != nil { + output.WriteString("Resource Modifiers: configured\n") + } + + output.WriteString("\n") + output.WriteString("=== RESTRICTED FIELDS (Not shown for non-admin users) ===\n") + output.WriteString("Backup Name: [RESTRICTED]\n") + output.WriteString("Schedule Name: [RESTRICTED]\n") + output.WriteString("Included Namespaces: [RESTRICTED]\n") + output.WriteString("Excluded Namespaces: [RESTRICTED]\n") + output.WriteString("Namespace Mapping: [RESTRICTED]\n") + + output.WriteString("\n") + + // Restore timing and progress - Status information (always shown when available) + if nar.Status.VeleroRestore != nil && nar.Status.VeleroRestore.Status != nil { + vStatus := nar.Status.VeleroRestore.Status + + output.WriteString("=== STATUS INFORMATION ===\n") + + if vStatus.StartTimestamp != nil { + output.WriteString(fmt.Sprintf("Started: %s\n", vStatus.StartTimestamp.Format("2006-01-02 15:04:05 -0700 MST"))) + } + if vStatus.CompletionTimestamp != nil { + output.WriteString(fmt.Sprintf("Completed: %s\n", vStatus.CompletionTimestamp.Format("2006-01-02 15:04:05 -0700 MST"))) + } + + output.WriteString("\n") + + // Progress information - Admin Enforceable: Yes + if vStatus.Progress != nil { + output.WriteString(fmt.Sprintf("Total items to be restored: %d\n", vStatus.Progress.TotalItems)) + output.WriteString(fmt.Sprintf("Items restored: %d\n", vStatus.Progress.ItemsRestored)) + } + + output.WriteString("\n") + + // Simplified resource list info - Admin Enforceable: Yes for counts + output.WriteString("Resource List:\n") + if vStatus.Progress != nil { + output.WriteString(fmt.Sprintf(" Total items restored: %d\n", vStatus.Progress.ItemsRestored)) + } else { + output.WriteString(" \n") + } + + output.WriteString("\n") + + // Volume information - Admin Enforceable: Yes for volume counts + output.WriteString("Restore Volumes:\n") + output.WriteString(" Persistent Volumes: \n") + + output.WriteString("\n") + + // Hooks information - Admin Enforceable: Yes for hook status + output.WriteString(fmt.Sprintf("Hooks Attempted: %d\n", vStatus.HookStatus.HooksAttempted)) + output.WriteString(fmt.Sprintf("Hooks Failed: %d\n", vStatus.HookStatus.HooksFailed)) + } + + return output.String() +} diff --git a/cmd/non-admin/restore/get.go b/cmd/non-admin/restore/get.go new file mode 100644 index 0000000..f97bf1c --- /dev/null +++ b/cmd/non-admin/restore/get.go @@ -0,0 +1,131 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" +) + +func NewGetCommand(f client.Factory, use string) *cobra.Command { + o := NewGetOptions() + + c := &cobra.Command{ + Use: use, + Short: "Get non-admin restores", + Long: "Get non-admin restores in the current namespace", + Run: func(c *cobra.Command, args []string) { + cmd.CheckError(o.Complete(args, f)) + cmd.CheckError(o.Validate(c, args, f)) + cmd.CheckError(o.Run(c, f)) + }, + Example: ` # List all non-admin restores in the current namespace + kubectl oadp nonadmin restore get + + # List restores in table format with extra columns + kubectl oadp nonadmin restore get --show-labels`, + } + + o.BindFlags(c.Flags()) + output.BindFlags(c.Flags()) + + return c +} + +type GetOptions struct { + client kbclient.WithWatch + currentNamespace string +} + +func NewGetOptions() *GetOptions { + return &GetOptions{} +} + +func (o *GetOptions) BindFlags(_flags *pflag.FlagSet) { + // Add any get-specific flags here if needed +} + +func (o *GetOptions) Complete(args []string, f client.Factory) error { + // Create client with NonAdmin scheme + client, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + }) + if err != nil { + return err + } + + // Get the current namespace from kubeconfig + currentNS, err := shared.GetCurrentNamespace() + if err != nil { + return fmt.Errorf("failed to determine current namespace: %w", err) + } + + o.client = client + o.currentNamespace = currentNS + return nil +} + +func (o *GetOptions) Validate(_c *cobra.Command, _args []string, _f client.Factory) error { + return nil +} + +func (o *GetOptions) Run(c *cobra.Command, _f client.Factory) error { + // List NonAdminRestore resources + restoreList := &nacv1alpha1.NonAdminRestoreList{} + + err := o.client.List(context.Background(), restoreList, &kbclient.ListOptions{ + Namespace: o.currentNamespace, + }) + if err != nil { + return fmt.Errorf("failed to list non-admin restores: %w", err) + } + + if len(restoreList.Items) == 0 { + fmt.Printf("No non-admin restores found in namespace %s.\n", o.currentNamespace) + return nil + } + + // Print results in table format + o.printTable(c, restoreList.Items) + + return nil +} + +func (o *GetOptions) printTable(_ *cobra.Command, restores []nacv1alpha1.NonAdminRestore) { + // Print header (backupName is not admin enforceable and therefore not displayed) + fmt.Printf("%-20s %-15s %-20s\n", "NAME", "PHASE", "CREATED") + fmt.Printf("%-20s %-15s %-20s\n", "----", "-----", "-------") + + // Print each restore + for _, restore := range restores { + name := restore.Name + phase := string(restore.Status.Phase) + if phase == "" { + phase = "Unknown" + } + + // Note: backupName is not admin enforceable and therefore not displayed + + created := restore.CreationTimestamp.Format("2006-01-02T15:04:05Z") + + fmt.Printf("%-20s %-15s %-20s\n", + truncateString(name, 20), + truncateString(phase, 15), + created) + } +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/cmd/non-admin/restore/logs.go b/cmd/non-admin/restore/logs.go new file mode 100644 index 0000000..d948194 --- /dev/null +++ b/cmd/non-admin/restore/logs.go @@ -0,0 +1,178 @@ +package restore + +/* +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. +*/ + +import ( + "bufio" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/client" +) + +func NewLogsCommand(f client.Factory, use string) *cobra.Command { + return &cobra.Command{ + Use: use + " NAME", + Short: "Get logs for a non-admin restore", + Long: "Display logs for a specified non-admin restore operation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Get the current namespace from kubectl context + userNamespace, err := shared.GetCurrentNamespace() + if err != nil { + return fmt.Errorf("failed to determine current namespace: %w", err) + } + restoreName := args[0] + + // Create scheme with required types + scheme, err := shared.NewSchemeWithTypes(shared.ClientOptions{ + IncludeNonAdminTypes: true, + IncludeVeleroTypes: true, + }) + if err != nil { + return err + } + + restConfig, err := f.ClientConfig() + if err != nil { + return fmt.Errorf("failed to get rest config: %w", err) + } + kbClient, err := kbclient.New(restConfig, kbclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + // Verify the NonAdminRestore exists before creating download request + var nar nacv1alpha1.NonAdminRestore + if err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: userNamespace, + Name: restoreName, + }, &nar); err != nil { + return fmt.Errorf("failed to get NonAdminRestore %q: %w", restoreName, err) + } + + req := &nacv1alpha1.NonAdminDownloadRequest{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: restoreName + "-logs-", + Namespace: userNamespace, + }, + Spec: nacv1alpha1.NonAdminDownloadRequestSpec{ + Target: velerov1.DownloadTarget{ + Kind: "RestoreLog", + Name: restoreName, // Use NonAdminRestore name, controller will resolve to Velero restore + }, + }, + } + + if err := kbClient.Create(ctx, req); err != nil { + return fmt.Errorf("failed to create NonAdminDownloadRequest: %w", err) + } + + defer func() { + deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 30*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 restore logs to be processed...") + Loop: + for { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for NonAdminDownloadRequest to be processed") + case <-tick: + fmt.Fprintf(cmd.OutOrStdout(), ".") + 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 != "" { + signedURL = updated.Status.VeleroDownloadRequest.Status.DownloadURL + fmt.Fprintf(cmd.OutOrStdout(), "\nDownload URL received, fetching logs...\n") + break Loop + } + } + } + + // 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) + } + } + } + } + + resp, err := http.Get(signedURL) + if err != nil { + return fmt.Errorf("failed to download logs: %w", 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) + } + + return nil + }, + Example: ` # Get logs for a non-admin restore + kubectl oadp nonadmin restore logs my-restore + + # Get logs for a restore in the current namespace + kubectl oadp nonadmin restore logs production-restore`, + } +} diff --git a/cmd/non-admin/restore/nonadminrestore_builder.go b/cmd/non-admin/restore/nonadminrestore_builder.go new file mode 100644 index 0000000..a9b10a3 --- /dev/null +++ b/cmd/non-admin/restore/nonadminrestore_builder.go @@ -0,0 +1,150 @@ +/* +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 restore + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" +) + +/* + +Example usage: + +var nonAdminRestore = ForNonAdminRestore("user-namespace", "restore-1"). + ObjectMeta( + WithLabels("foo", "bar"), + ). + RestoreSpec(nacv1alpha1.NonAdminRestoreSpec{ + RestoreSpec: &velerov1api.RestoreSpec{ + BackupName: "backup-1", + }, + }). + Result() + +*/ + +// NonAdminRestoreBuilder builds NonAdminRestore objects. +type NonAdminRestoreBuilder struct { + object *nacv1alpha1.NonAdminRestore +} + +// ForNonAdminRestore is the constructor for a NonAdminRestoreBuilder. +func ForNonAdminRestore(ns, name string) *NonAdminRestoreBuilder { + return &NonAdminRestoreBuilder{ + object: &nacv1alpha1.NonAdminRestore{ + TypeMeta: metav1.TypeMeta{ + APIVersion: nacv1alpha1.GroupVersion.String(), + Kind: "NonAdminRestore", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + }, + }, + } +} + +// Result returns the built NonAdminRestore. +func (b *NonAdminRestoreBuilder) Result() *nacv1alpha1.NonAdminRestore { + return b.object +} + +// ObjectMeta applies functional options to the NonAdminRestore's ObjectMeta. +func (b *NonAdminRestoreBuilder) ObjectMeta(opts ...ObjectMetaOpt) *NonAdminRestoreBuilder { + for _, opt := range opts { + opt(b.object) + } + + return b +} + +// RestoreSpec sets the NonAdminRestore's spec. +func (b *NonAdminRestoreBuilder) RestoreSpec(spec nacv1alpha1.NonAdminRestoreSpec) *NonAdminRestoreBuilder { + b.object.Spec = spec + return b +} + +// Phase sets the NonAdminRestore's status phase. +func (b *NonAdminRestoreBuilder) Phase(phase nacv1alpha1.NonAdminPhase) *NonAdminRestoreBuilder { + b.object.Status.Phase = phase + return b +} + +// ObjectMetaOpt is a functional option for setting fields on a NonAdminRestore's ObjectMeta. +type ObjectMetaOpt func(*nacv1alpha1.NonAdminRestore) + +// WithLabels sets the NonAdminRestore's labels. +func WithLabels(labels ...string) ObjectMetaOpt { + return func(obj *nacv1alpha1.NonAdminRestore) { + if len(labels)%2 != 0 { + panic("labels must be specified in pairs") + } + + if obj.Labels == nil { + obj.Labels = make(map[string]string) + } + + for i := 0; i < len(labels); i += 2 { + obj.Labels[labels[i]] = labels[i+1] + } + } +} + +// WithLabelsMap sets the NonAdminRestore's labels. +func WithLabelsMap(labels map[string]string) ObjectMetaOpt { + return func(obj *nacv1alpha1.NonAdminRestore) { + if obj.Labels == nil { + obj.Labels = make(map[string]string) + } + + for k, v := range labels { + obj.Labels[k] = v + } + } +} + +// WithAnnotations sets the NonAdminRestore's annotations. +func WithAnnotations(annotations ...string) ObjectMetaOpt { + return func(obj *nacv1alpha1.NonAdminRestore) { + if len(annotations)%2 != 0 { + panic("annotations must be specified in pairs") + } + + if obj.Annotations == nil { + obj.Annotations = make(map[string]string) + } + + for i := 0; i < len(annotations); i += 2 { + obj.Annotations[annotations[i]] = annotations[i+1] + } + } +} + +// WithAnnotationsMap sets the NonAdminRestore's annotations. +func WithAnnotationsMap(annotations map[string]string) ObjectMetaOpt { + return func(obj *nacv1alpha1.NonAdminRestore) { + if obj.Annotations == nil { + obj.Annotations = make(map[string]string) + } + + for k, v := range annotations { + obj.Annotations[k] = v + } + } +} diff --git a/cmd/non-admin/restore/restore.go b/cmd/non-admin/restore/restore.go new file mode 100644 index 0000000..04a69fb --- /dev/null +++ b/cmd/non-admin/restore/restore.go @@ -0,0 +1,42 @@ +package restore + +/* +Copyright 2017 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. +*/ + +import ( + "github.com/spf13/cobra" + + "github.com/vmware-tanzu/velero/pkg/client" +) + +// NewRestoreCommand creates the "restore" subcommand under nonadmin +func NewRestoreCommand(f client.Factory) *cobra.Command { + c := &cobra.Command{ + Use: "restore", + Short: "Work with non-admin restores", + Long: "Work with non-admin restores", + } + + c.AddCommand( + NewCreateCommand(f, "create"), + NewGetCommand(f, "get"), + NewLogsCommand(f, "logs"), + NewDescribeCommand(f, "describe"), + NewDeleteCommand(f, "delete"), + ) + + return c +} diff --git a/cmd/non-admin/restore/restore_test.go b/cmd/non-admin/restore/restore_test.go new file mode 100644 index 0000000..6fc5755 --- /dev/null +++ b/cmd/non-admin/restore/restore_test.go @@ -0,0 +1,259 @@ +/* +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 restore + +import ( + "testing" + + "github.com/migtools/oadp-cli/internal/testutil" +) + +// TestNonAdminRestoreCommands tests the non-admin restore command functionality +func TestNonAdminRestoreCommands(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + tests := []struct { + name string + args []string + expectContains []string + }{ + { + name: "nonadmin restore help", + args: []string{"nonadmin", "restore", "--help"}, + expectContains: []string{ + "Work with non-admin restores", + "create", + "describe", + "delete", + "get", + "logs", + }, + }, + { + name: "nonadmin restore create help", + args: []string{"nonadmin", "restore", "create", "--help"}, + expectContains: []string{ + "Create a non-admin restore", + "--from-backup", + "--include-resources", + "--exclude-resources", + "--wait", + }, + }, + { + name: "nonadmin restore describe help", + args: []string{"nonadmin", "restore", "describe", "--help"}, + expectContains: []string{ + "Describe a non-admin restore", + }, + }, + { + name: "nonadmin restore delete help", + args: []string{"nonadmin", "restore", "delete", "--help"}, + expectContains: []string{ + "Delete one or more non-admin restores", + }, + }, + { + name: "nonadmin restore get help", + args: []string{"nonadmin", "restore", "get", "--help"}, + expectContains: []string{ + "Get non-admin restores in the current namespace", + }, + }, + { + name: "nonadmin restore logs help", + args: []string{"nonadmin", "restore", "logs", "--help"}, + expectContains: []string{ + "Display logs for a specified non-admin restore operation", + }, + }, + { + name: "na restore shorthand help", + args: []string{"na", "restore", "--help"}, + expectContains: []string{ + "Work with non-admin restores", + "create", + "describe", + "delete", + "get", + "logs", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, tt.args, tt.expectContains) + }) + } +} + +// TestNonAdminRestoreHelpFlags tests that both --help and -h work for restore commands +func TestNonAdminRestoreHelpFlags(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + commands := [][]string{ + {"nonadmin", "restore", "--help"}, + {"nonadmin", "restore", "-h"}, + {"nonadmin", "restore", "create", "--help"}, + {"nonadmin", "restore", "create", "-h"}, + {"nonadmin", "restore", "describe", "--help"}, + {"nonadmin", "restore", "describe", "-h"}, + {"nonadmin", "restore", "delete", "--help"}, + {"nonadmin", "restore", "delete", "-h"}, + {"nonadmin", "restore", "get", "--help"}, + {"nonadmin", "restore", "get", "-h"}, + {"nonadmin", "restore", "logs", "--help"}, + {"nonadmin", "restore", "logs", "-h"}, + {"na", "restore", "--help"}, + {"na", "restore", "-h"}, + } + + for _, cmd := range commands { + t.Run("help_flags_"+cmd[len(cmd)-1], func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, cmd, []string{"Usage:"}) + }) + } +} + +// TestNonAdminRestoreCreateFlags tests create command specific flags +func TestNonAdminRestoreCreateFlags(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("create command has all expected flags", func(t *testing.T) { + expectedFlags := []string{ + "--from-backup", + "--include-resources", + "--exclude-resources", + "--labels", + "--annotations", + "--wait", + "--selector", + "--or-selector", + "--include-cluster-resources", + "--restore-volumes", + "--preserve-nodeports", + "--item-operation-timeout", + "--existing-resource-policy", + } + + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "create", "--help"}, + expectedFlags) + }) +} + +// TestNonAdminRestoreExamples tests that help text contains proper examples +func TestNonAdminRestoreExamples(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("create examples use correct command format", func(t *testing.T) { + expectedExamples := []string{ + "kubectl oadp nonadmin restore create", + "--from-backup", + "--include-resources", + "--exclude-resources", + "--wait", + } + + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "create", "--help"}, + expectedExamples) + }) + + t.Run("main restore help shows subcommands", func(t *testing.T) { + expectedSubcommands := []string{ + "create", + "delete", + "describe", + "get", + "logs", + } + + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "--help"}, + expectedSubcommands) + }) +} + +// TestNonAdminRestoreClientConfigIntegration tests that restore commands respect client config +func TestNonAdminRestoreClientConfigIntegration(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + _, cleanup := testutil.SetupTempHome(t) + defer cleanup() + + t.Run("restore commands work with client config", func(t *testing.T) { + // Set a known namespace + _, err := testutil.RunCommand(t, binaryPath, "client", "config", "set", "namespace=user-namespace") + if err != nil { + t.Fatalf("Failed to set client config: %v", err) + } + + // Test that restore commands can be invoked (they should respect the namespace) + // We test help commands since they don't require actual K8s resources + commands := [][]string{ + {"nonadmin", "restore", "get", "--help"}, + {"nonadmin", "restore", "create", "--help"}, + {"nonadmin", "restore", "describe", "--help"}, + {"nonadmin", "restore", "delete", "--help"}, + {"nonadmin", "restore", "logs", "--help"}, + {"na", "restore", "get", "--help"}, + } + + for _, cmd := range commands { + t.Run("config_test_"+cmd[len(cmd)-2], func(t *testing.T) { + output, err := testutil.RunCommand(t, binaryPath, cmd...) + if err != nil { + t.Fatalf("Non-admin restore command should work with client config: %v", err) + } + if output == "" { + t.Errorf("Expected help output for %v", cmd) + } + }) + } + }) +} + +// TestNonAdminRestoreCommandStructure tests the overall command structure +func TestNonAdminRestoreCommandStructure(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("restore commands available under nonadmin", func(t *testing.T) { + _, err := testutil.RunCommand(t, binaryPath, "nonadmin", "--help") + if err != nil { + t.Fatalf("nonadmin command should exist: %v", err) + } + + expectedCommands := []string{"restore"} + for _, cmd := range expectedCommands { + testutil.TestHelpCommand(t, binaryPath, []string{"nonadmin", "--help"}, []string{cmd}) + } + }) + + t.Run("restore commands available under na shorthand", func(t *testing.T) { + _, err := testutil.RunCommand(t, binaryPath, "na", "--help") + if err != nil { + t.Fatalf("na command should exist: %v", err) + } + + expectedCommands := []string{"restore"} + for _, cmd := range expectedCommands { + testutil.TestHelpCommand(t, binaryPath, []string{"na", "--help"}, []string{cmd}) + } + }) +} diff --git a/go.mod b/go.mod index 8120d7e..88d3a0c 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 - github.com/vmware-tanzu/velero v1.14.0 - gopkg.in/yaml.v2 v2.4.0 + github.com/vmware-tanzu/velero v1.16.1 k8s.io/api v0.33.1 k8s.io/apimachinery v0.33.1 k8s.io/client-go v0.33.1 diff --git a/oadp-cli b/oadp-cli new file mode 100755 index 0000000..a6f88d0 Binary files /dev/null and b/oadp-cli differ