diff --git a/.gitignore b/.gitignore index 0bd2c3e..bd2d8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.dylib # Built binaries +oadp kubectl-oadp kubectl-oadp-linux-* kubectl-oadp-darwin-* diff --git a/Makefile b/Makefile index 9106165..32cd865 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ help: ## Show this help message @echo " make test-unit # Run unit tests only" @echo " make test-integration # Run integration tests only" @echo " make lint # Run golangci-lint checks" + @echo " make lint-fix # Run golangci-lint auto-fix and format code" @echo "" @echo "Release commands:" @echo " make release-build # Build binaries for all platforms" @@ -408,6 +409,14 @@ test-integration: ## Run integration tests only lint: golangci-lint ## Run golangci-lint checks against all project's Go files $(GOLANGCI_LINT) run ./... +.PHONY: lint-fix +lint-fix: golangci-lint ## Run golangci-lint auto-fix and format code + @echo "Running golangci-lint with auto-fix..." + $(GOLANGCI_LINT) run --fix ./... + @echo "Running go fmt..." + go fmt ./... + @echo "✅ Linting and formatting complete!" + # Cleanup targets .PHONY: clean clean: ## Remove built binaries and downloaded tools diff --git a/cmd/non-admin/nonadmin.go b/cmd/non-admin/nonadmin.go index 7c5cb9b..dd6c08e 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/migtools/oadp-cli/cmd/non-admin/verbs" "github.com/spf13/cobra" "github.com/vmware-tanzu/velero/pkg/client" @@ -29,13 +30,16 @@ 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/restore/create.go b/cmd/non-admin/restore/create.go new file mode 100644 index 0000000..932b877 --- /dev/null +++ b/cmd/non-admin/restore/create.go @@ -0,0 +1,247 @@ +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" + corev1 "k8s.io/api/core/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" + "github.com/vmware-tanzu/velero/pkg/builder" + "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", + 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. + kubectl oadp nonadmin restore create restore1 --backup-name backup1 + + # Create a non-admin restore with namespace mapping. + kubectl oadp nonadmin restore create restore2 --backup-name backup1 --namespace-mappings old-ns=new-ns + + # Create a non-admin restore with specific resource types. + kubectl oadp nonadmin restore create restore3 --backup-name backup1 --include-resources deployments,services + + # Create a non-admin restore excluding certain resources. + kubectl oadp nonadmin restore create restore4 --backup-name backup1 --exclude-resources secrets + + # View the YAML for a non-admin restore without sending it to the server. + kubectl oadp nonadmin restore create restore5 --backup-name backup1 -o yaml`, + } + + o.BindFlags(c.Flags()) + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +type CreateOptions struct { + Name string + BackupName string + IncludeNamespaces flag.StringArray + ExcludeNamespaces flag.StringArray + IncludeResources flag.StringArray + ExcludeResources flag.StringArray + NamespaceMappings flag.Map + Labels flag.Map + Annotations flag.Map + Selector flag.LabelSelector + OrSelector flag.OrLabelSelector + RestoreVolumes flag.OptionalBool + PreserveNodePorts flag.OptionalBool + IncludeClusterResources flag.OptionalBool + ExistingResourcePolicy string + ItemOperationTimeout time.Duration + ResourceModifierConfigMap string + client kbclient.WithWatch + currentNamespace string +} + +func NewCreateOptions() *CreateOptions { + return &CreateOptions{ + Labels: flag.NewMap(), + Annotations: flag.NewMap(), + NamespaceMappings: flag.NewMap(), + } +} + +func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.BackupName, "backup-name", "", "The backup to restore from (required).") + flags.Var(&o.IncludeNamespaces, "include-namespaces", "Namespaces to include in the restore (use '*' for all namespaces).") + flags.Var(&o.ExcludeNamespaces, "exclude-namespaces", "Namespaces to exclude from the restore.") + 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.NamespaceMappings, "namespace-mappings", "Namespace mappings from name in the backup to desired restored name in the form src1=dst1,src2=dst2,...") + 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.StringVar(&o.ExistingResourcePolicy, "existing-resource-policy", "", "Policy to handle restore of items that already exist in the cluster. Options are 'none' and 'update'.") + flags.DurationVar(&o.ItemOperationTimeout, "item-operation-timeout", o.ItemOperationTimeout, "How long to wait for async plugin operations before timeout.") + flags.StringVar(&o.ResourceModifierConfigMap, "resource-modifier-configmap", "", "Reference to the resource modifier configmap that restore should use") + + f := flags.VarPF(&o.RestoreVolumes, "restore-volumes", "", "Whether to restore volumes from snapshots. If the parameter is not set, it is treated as setting to 'true'.") + f.NoOptDefVal = cmd.TRUE + + f = flags.VarPF(&o.PreserveNodePorts, "preserve-nodeports", "", "Whether to preserve nodeports when restoring services.") + f.NoOptDefVal = cmd.TRUE + + f = flags.VarPF(&o.IncludeClusterResources, "include-cluster-resources", "", "Include cluster-scoped resources in the restore.") + f.NoOptDefVal = cmd.TRUE +} + +func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { + if err := output.ValidateFlags(c); err != nil { + return err + } + + if len(args) != 1 { + return fmt.Errorf("a restore name is required") + } + + if o.BackupName == "" { + return fmt.Errorf("--backup-name is required") + } + + if o.Selector.LabelSelector != nil && o.OrSelector.OrLabelSelectors != nil { + return fmt.Errorf("either a 'selector' or an 'or-selector' can be specified, but not both") + } + + return nil +} + +func (o *CreateOptions) Complete(args []string, f client.Factory) error { + 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) Run(c *cobra.Command, f client.Factory) error { + nonAdminRestore, err := o.BuildNonAdminRestore(o.currentNamespace) + if err != nil { + return err + } + + if printed, err := output.PrintWithFormat(c, nonAdminRestore); printed || err != nil { + return err + } + + // Create the restore + if err := o.client.Create(context.TODO(), nonAdminRestore, &kbclient.CreateOptions{}); err != nil { + return err + } + + fmt.Printf("NonAdminRestore request %q submitted successfully.\n", nonAdminRestore.Name) + fmt.Printf("Run `oc oadp nonadmin restore describe %s` or `oc oadp nonadmin restore logs %s` for more details.\n", nonAdminRestore.Name, nonAdminRestore.Name) + return nil +} + +func (o *CreateOptions) BuildNonAdminRestore(namespace string) (*nacv1alpha1.NonAdminRestore, error) { + // Use Velero's builder for RestoreSpec + restoreBuilder := builder.ForRestore(namespace, o.Name). + Backup(o.BackupName). + IncludedNamespaces(o.IncludeNamespaces...). + ExcludedNamespaces(o.ExcludeNamespaces...) + + // Convert namespace mappings from map to alternating key-value pairs + if len(o.NamespaceMappings.Data()) > 0 { + mappings := make([]string, 0, len(o.NamespaceMappings.Data())*2) + for k, v := range o.NamespaceMappings.Data() { + mappings = append(mappings, k, v) + } + restoreBuilder.NamespaceMappings(mappings...) + } + + restoreBuilder. + IncludedResources(o.IncludeResources...). + ExcludedResources(o.ExcludeResources...). + LabelSelector(o.Selector.LabelSelector). + OrLabelSelector(o.OrSelector.OrLabelSelectors). + ItemOperationTimeout(o.ItemOperationTimeout). + ExistingResourcePolicy(o.ExistingResourcePolicy) + + // Apply optional bools + if o.RestoreVolumes.Value != nil { + restoreBuilder.RestorePVs(*o.RestoreVolumes.Value) + } + if o.PreserveNodePorts.Value != nil { + restoreBuilder.PreserveNodePorts(*o.PreserveNodePorts.Value) + } + if o.IncludeClusterResources.Value != nil { + restoreBuilder.IncludeClusterResources(*o.IncludeClusterResources.Value) + } + + tempRestore := restoreBuilder.Result() + + // Set ResourceModifier manually since there's no builder method + if o.ResourceModifierConfigMap != "" { + tempRestore.Spec.ResourceModifier = &corev1.TypedLocalObjectReference{ + Kind: "ConfigMap", + Name: o.ResourceModifierConfigMap, + } + } + + // Wrap in NonAdminRestore + return ForNonAdminRestore(namespace, o.Name). + ObjectMeta( + WithLabelsMap(o.Labels.Data()), + WithAnnotationsMap(o.Annotations.Data()), + ). + RestoreSpec(nacv1alpha1.NonAdminRestoreSpec{ + RestoreSpec: &tempRestore.Spec, + }). + Result(), nil +} diff --git a/cmd/non-admin/restore/delete.go b/cmd/non-admin/restore/delete.go new file mode 100644 index 0000000..3422a40 --- /dev/null +++ b/cmd/non-admin/restore/delete.go @@ -0,0 +1,287 @@ +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...] | --all", + Short: "Delete one or more non-admin restores", + Long: "Delete one or more non-admin restores. Use --all to delete all restores in the current namespace.", + Args: func(cmd *cobra.Command, args []string) error { + // Check if --all flag is set + allFlag, _ := cmd.Flags().GetBool("all") + if allFlag { + return cobra.NoArgs(cmd, args) + } + return cobra.MinimumNArgs(1)(cmd, args) + }, + 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 + All bool // Delete all restores in namespace + 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") + flags.BoolVar(&o.All, "all", false, "Delete all restores in the current namespace") +} + +// 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 + + // If --all flag is used, list all restores in the namespace + if o.All { + var narList nacv1alpha1.NonAdminRestoreList + err := o.client.List(context.TODO(), &narList, &kbclient.ListOptions{ + Namespace: o.Namespace, + }) + if err != nil { + return fmt.Errorf("failed to list restores: %w", err) + } + + // Extract restore names + o.Names = make([]string, 0, len(narList.Items)) + for _, nar := range narList.Items { + o.Names = append(o.Names, nar.Name) + } + + if len(o.Names) == 0 { + return fmt.Errorf("no restores found in namespace '%s'", o.Namespace) + } + } + + return nil +} + +// Validate validates the options +func (o *DeleteOptions) Validate() error { + if !o.All && len(o.Names) == 0 { + return fmt.Errorf("at least one restore name is required, or use --all to delete all restores") + } + 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 + if o.All { + fmt.Printf("All NonAdminRestore(s) in namespace '%s' will be deleted:\n", o.Namespace) + } else { + fmt.Printf("The following NonAdminRestore(s) will be deleted in 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 successfully\n", name) + successful = append(successful, name) + } + } + + // Print summary + fmt.Println() + if len(successful) > 0 { + fmt.Printf("Successfully deleted %d restore(s)\n", len(successful)) + } + + 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 o.All { + fmt.Printf("Are you sure you want to delete ALL %d restore(s) in namespace '%s'? (y/N): ", len(o.Names), o.Namespace) + } else 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 + 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..766eac1 --- /dev/null +++ b/cmd/non-admin/restore/describe.go @@ -0,0 +1,539 @@ +package restore + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewDescribeCommand(f client.Factory, use string) *cobra.Command { + var ( + requestTimeout time.Duration + details bool + ) + + c := &cobra.Command{ + Use: use + " NAME", + Short: "Describe a non-admin restore", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + restoreName := args[0] + + // Get effective timeout (flag takes precedence over env var) + effectiveTimeout := shared.GetHTTPTimeoutWithOverride(requestTimeout) + + // Create context with the effective timeout + ctx, cancel := context.WithTimeout(context.Background(), effectiveTimeout) + 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) + } + + // Create client with required scheme types and timeout + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + IncludeVeleroTypes: true, + IncludeCoreTypes: true, + Timeout: effectiveTimeout, + }) + if err != nil { + return err + } + + // Get the specific restore + var nar nacv1alpha1.NonAdminRestore + if err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: userNamespace, + Name: restoreName, + }, &nar); err != nil { + // Check for context cancellation + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timed out after %v getting NonAdminRestore %q", effectiveTimeout, restoreName) + } + if ctx.Err() == context.Canceled { + return fmt.Errorf("operation cancelled: %w", ctx.Err()) + } + return fmt.Errorf("NonAdminRestore %q not found in namespace %q: %w", restoreName, userNamespace, err) + } + + // Print in Velero-style format + printNonAdminRestoreDetails(cmd, &nar, kbClient, restoreName, userNamespace, effectiveTimeout) + + // Add detailed output if --details flag is set + if details { + if err := printDetailedRestoreInfo(cmd, kbClient, restoreName, userNamespace, effectiveTimeout); err != nil { + return fmt.Errorf("failed to fetch detailed restore information: %w", err) + } + } + + return nil + }, + Example: ` kubectl oadp nonadmin restore describe my-restore + kubectl oadp nonadmin restore describe my-restore --details + kubectl oadp nonadmin restore describe my-restore --details --request-timeout=30m`, + } + + c.Flags().DurationVar(&requestTimeout, "request-timeout", 0, fmt.Sprintf("The length of time to wait before giving up on a single server request (e.g., 30s, 5m, 1h). Overrides %s env var. Default: %v", shared.TimeoutEnvVar, shared.DefaultHTTPTimeout)) + c.Flags().BoolVar(&details, "details", false, "Display additional restore details including resource lists and item operations") + + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +// printNonAdminRestoreDetails prints restore details in Velero admin describe format +func printNonAdminRestoreDetails(cmd *cobra.Command, nar *nacv1alpha1.NonAdminRestore, kbClient kbclient.Client, restoreName string, userNamespace string, timeout time.Duration) { + out := cmd.OutOrStdout() + + // Get Velero restore reference if available + var vr *nacv1alpha1.VeleroRestore + if nar.Status.VeleroRestore != nil { + vr = nar.Status.VeleroRestore + } + + // Name and Namespace + fmt.Fprintf(out, "Name: %s\n", nar.Name) + fmt.Fprintf(out, "Namespace: %s\n", nar.Namespace) + + // Labels + fmt.Fprintf(out, "Labels: ") + if len(nar.Labels) == 0 { + fmt.Fprintf(out, "\n") + } else { + labelKeys := make([]string, 0, len(nar.Labels)) + for k := range nar.Labels { + labelKeys = append(labelKeys, k) + } + sort.Strings(labelKeys) + for i, k := range labelKeys { + if i == 0 { + fmt.Fprintf(out, "%s=%s\n", k, nar.Labels[k]) + } else { + fmt.Fprintf(out, " %s=%s\n", k, nar.Labels[k]) + } + } + } + + // Annotations + fmt.Fprintf(out, "Annotations: ") + if len(nar.Annotations) == 0 { + fmt.Fprintf(out, "\n") + } else { + annotationKeys := make([]string, 0, len(nar.Annotations)) + for k := range nar.Annotations { + annotationKeys = append(annotationKeys, k) + } + sort.Strings(annotationKeys) + for i, k := range annotationKeys { + if i == 0 { + fmt.Fprintf(out, "%s=%s\n", k, nar.Annotations[k]) + } else { + fmt.Fprintf(out, " %s=%s\n", k, nar.Annotations[k]) + } + } + } + + fmt.Fprintf(out, "\n") + + // Phase (with color) + phase := string(nar.Status.Phase) + if vr != nil && vr.Status != nil && vr.Status.Phase != "" { + phase = string(vr.Status.Phase) + } + fmt.Fprintf(out, "Phase: %s\n", colorizePhase(phase)) + + fmt.Fprintf(out, "\n") + + // Restore Spec details + if nar.Spec.RestoreSpec != nil { + spec := nar.Spec.RestoreSpec + + // Source Backup + if spec.BackupName != "" { + fmt.Fprintf(out, "Backup: %s\n", spec.BackupName) + } else { + fmt.Fprintf(out, "Backup: \n") + } + + fmt.Fprintf(out, "\n") + + // 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") + + // Namespace Mappings + if len(spec.NamespaceMapping) == 0 { + fmt.Fprintf(out, "Namespace mappings: \n") + } else { + fmt.Fprintf(out, "Namespace mappings:\n") + // Sort the mappings for consistent output + mappingKeys := make([]string, 0, len(spec.NamespaceMapping)) + for k := range spec.NamespaceMapping { + mappingKeys = append(mappingKeys, k) + } + sort.Strings(mappingKeys) + for _, from := range mappingKeys { + fmt.Fprintf(out, " %s: %s\n", from, spec.NamespaceMapping[from]) + } + } + + 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") + } + + 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") + + // Restore PVs setting + if spec.RestorePVs != nil { + if *spec.RestorePVs { + fmt.Fprintf(out, "Restore PVs: true\n") + } else { + fmt.Fprintf(out, "Restore PVs: false\n") + } + } else { + fmt.Fprintf(out, "Restore PVs: auto\n") + } + + fmt.Fprintf(out, "\n") + + // Existing Resource Policy + if spec.ExistingResourcePolicy != "" { + fmt.Fprintf(out, "Existing Resource Policy: %s\n", spec.ExistingResourcePolicy) + } else { + fmt.Fprintf(out, "Existing Resource Policy: \n") + } + + fmt.Fprintf(out, "\n") + + // Item Operation Timeout + 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") + } + + // Velero restore status information + if vr != nil && vr.Status != nil { + status := vr.Status + + // 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") + + // Progress + if status.Progress != nil { + fmt.Fprintf(out, "Total items to be restored: %d\n", status.Progress.TotalItems) + fmt.Fprintf(out, "Items restored: %d\n", status.Progress.ItemsRestored) + } + + fmt.Fprintf(out, "\n") + + // Warnings and Errors + if status.Warnings > 0 { + fmt.Fprintf(out, "Warnings: %d\n", status.Warnings) + } + if status.Errors > 0 { + fmt.Fprintf(out, "Errors: %d\n", status.Errors) + } + + 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 restore not available yet + fmt.Fprintf(out, "Velero restore information not yet available.\n") + fmt.Fprintf(out, "Request Phase: %s\n", nar.Status.Phase) + } +} + +// printDetailedRestoreInfo fetches and displays additional restore details when --details flag is used. +// It uses NonAdminDownloadRequest to fetch: +// - RestoreResourceList (list of restored resources) +// - RestoreResults (errors, warnings) +// - RestoreItemOperations (plugin operations) +func printDetailedRestoreInfo(cmd *cobra.Command, kbClient kbclient.Client, restoreName string, userNamespace string, timeout time.Duration) error { + out := cmd.OutOrStdout() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + hasOutput := false + + // 1. Fetch RestoreResourceList + resourceList, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: restoreName, + DataType: "RestoreResourceList", + Namespace: userNamespace, + HTTPTimeout: timeout, + }) + + if err == nil && resourceList != "" { + if formattedList := formatRestoreResourceList(resourceList); formattedList != "" { + if !hasOutput { + fmt.Fprintf(out, "\n") + hasOutput = true + } + fmt.Fprintf(out, "Resource List:\n") + fmt.Fprintf(out, "%s\n", formattedList) + fmt.Fprintf(out, "\n") + } + } + + // 2. Fetch RestoreResults + results, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: restoreName, + DataType: "RestoreResults", + Namespace: userNamespace, + HTTPTimeout: timeout, + }) + + if err == nil && results != "" { + if formattedResults := formatRestoreResults(results); formattedResults != "" { + if !hasOutput { + fmt.Fprintf(out, "\n") + hasOutput = true + } + fmt.Fprintf(out, "Restore Results:\n") + fmt.Fprintf(out, "%s\n", formattedResults) + fmt.Fprintf(out, "\n") + } + } + + // 3. Fetch RestoreItemOperations + itemOps, err := shared.ProcessDownloadRequest(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: restoreName, + DataType: "RestoreItemOperations", + Namespace: userNamespace, + HTTPTimeout: timeout, + }) + + if err == nil && itemOps != "" { + if formattedOps := formatRestoreItemOperations(itemOps); formattedOps != "" { + if !hasOutput { + fmt.Fprintf(out, "\n") + } + fmt.Fprintf(out, "Restore Item Operations:\n") + fmt.Fprintf(out, "%s\n", formattedOps) + fmt.Fprintf(out, "\n") + } + } + + return nil +} + +// formatRestoreResourceList formats the resource list for display +func formatRestoreResourceList(resourceList string) string { + if strings.TrimSpace(resourceList) == "" { + return "" + } + + // Try to parse as JSON map + var resources map[string][]string + if err := json.Unmarshal([]byte(resourceList), &resources); err != nil { + // If parsing fails, fall back to indented output + return indent(resourceList, " ") + } + + // Sort the keys (GroupVersionKind) + keys := make([]string, 0, len(resources)) + for k := range resources { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build formatted output + var output strings.Builder + for _, gvk := range keys { + items := resources[gvk] + output.WriteString(fmt.Sprintf(" %s:\n", gvk)) + for _, item := range items { + output.WriteString(fmt.Sprintf(" - %s\n", item)) + } + } + + return strings.TrimSuffix(output.String(), "\n") +} + +// formatRestoreResults formats restore results (errors/warnings) for display +func formatRestoreResults(results string) string { + if strings.TrimSpace(results) == "" { + return "" + } + + // Try to parse as JSON object with errors and warnings + var resultsObj struct { + Errors map[string]interface{} `json:"errors"` + Warnings map[string]interface{} `json:"warnings"` + } + if err := json.Unmarshal([]byte(results), &resultsObj); err != nil { + // If parsing fails, fall back to indented output + return indent(results, " ") + } + + // If both are empty, return empty string so section won't be printed + if len(resultsObj.Errors) == 0 && len(resultsObj.Warnings) == 0 { + return "" + } + + // Format nicely + var output strings.Builder + + // Show errors + output.WriteString(" Errors:\n") + if len(resultsObj.Errors) > 0 { + formatted, _ := json.MarshalIndent(resultsObj.Errors, " ", " ") + output.WriteString(indent(string(formatted), " ")) + } else { + output.WriteString(" ") + } + output.WriteString("\n\n") + + // Show warnings + output.WriteString(" Warnings:\n") + if len(resultsObj.Warnings) > 0 { + formatted, _ := json.MarshalIndent(resultsObj.Warnings, " ", " ") + output.WriteString(indent(string(formatted), " ")) + } else { + output.WriteString(" ") + } + + return strings.TrimSuffix(output.String(), "\n") +} + +// formatRestoreItemOperations formats restore item operations for display +func formatRestoreItemOperations(itemOps string) string { + if strings.TrimSpace(itemOps) == "" { + return "" + } + + // Try to parse as JSON array + var operations []interface{} + if err := json.Unmarshal([]byte(itemOps), &operations); err != nil { + // If parsing fails, fall back to indented output + return indent(itemOps, " ") + } + + // If empty array, return empty string (will show "") + if len(operations) == 0 { + return "" + } + + // Format as indented JSON for readability + formatted, err := json.MarshalIndent(operations, " ", " ") + if err != nil { + return indent(itemOps, " ") + } + return indent(string(formatted), " ") +} + +// 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 + } +} + +// 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 + } + } + return strings.Join(lines, "\n") +} diff --git a/cmd/non-admin/restore/get.go b/cmd/non-admin/restore/get.go new file mode 100644 index 0000000..6b14224 --- /dev/null +++ b/cmd/non-admin/restore/get.go @@ -0,0 +1,187 @@ +/* +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 ( + "context" + "fmt" + "time" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewGetCommand(f client.Factory, use string) *cobra.Command { + c := &cobra.Command{ + Use: use + " [NAME]", + Short: "Get non-admin restore(s)", + Long: "Get one or more non-admin restores", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // 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 full scheme + kbClient, err := shared.NewClientWithFullScheme(f) + if err != nil { + return err + } + + if len(args) == 1 { + // Get specific restore + restoreName := args[0] + var nar nacv1alpha1.NonAdminRestore + err := kbClient.Get(context.Background(), kbclient.ObjectKey{ + Namespace: userNamespace, + Name: restoreName, + }, &nar) + if err != nil { + return fmt.Errorf("failed to get NonAdminRestore %q: %w", restoreName, err) + } + + if printed, err := output.PrintWithFormat(cmd, &nar); printed || err != nil { + return err + } + + // If no output format specified, print table format for single item + list := &nacv1alpha1.NonAdminRestoreList{ + Items: []nacv1alpha1.NonAdminRestore{nar}, + } + return printNonAdminRestoreTable(list) + } else { + // List all restores in namespace + var narList nacv1alpha1.NonAdminRestoreList + err := kbClient.List(context.Background(), &narList, &kbclient.ListOptions{ + Namespace: userNamespace, + }) + if err != nil { + return fmt.Errorf("failed to list NonAdminRestores: %w", err) + } + + if printed, err := output.PrintWithFormat(cmd, &narList); printed || err != nil { + return err + } + + // Print table format + return printNonAdminRestoreTable(&narList) + } + }, + Example: ` # Get all non-admin restores in the current namespace + kubectl oadp nonadmin restore get + + # Get a specific non-admin restore + kubectl oadp nonadmin restore get my-restore + + # Get restores in YAML format + kubectl oadp nonadmin restore get -o yaml + + # Get a specific restore in JSON format + kubectl oadp nonadmin restore get my-restore -o json`, + } + + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +func printNonAdminRestoreTable(narList *nacv1alpha1.NonAdminRestoreList) error { + if len(narList.Items) == 0 { + fmt.Println("No non-admin restores found.") + return nil + } + + // Print header + fmt.Printf("%-30s %-15s %-15s %-20s %-10s %-10s\n", "NAME", "REQUEST PHASE", "VELERO PHASE", "CREATED", "AGE", "DURATION") + + // Print each restore + for _, nar := range narList.Items { + status := getRestoreStatus(&nar) + veleroPhase := getVeleroRestorePhase(&nar) + created := nar.CreationTimestamp.Format("2006-01-02 15:04:05") + age := formatAge(nar.CreationTimestamp.Time) + duration := getRestoreDuration(&nar) + + fmt.Printf("%-30s %-15s %-15s %-20s %-10s %-10s\n", nar.Name, status, veleroPhase, created, age, duration) + } + + return nil +} + +func getRestoreStatus(nar *nacv1alpha1.NonAdminRestore) string { + if nar.Status.Phase != "" { + return string(nar.Status.Phase) + } + return "Unknown" +} + +func getVeleroRestorePhase(nar *nacv1alpha1.NonAdminRestore) string { + if nar.Status.VeleroRestore != nil && nar.Status.VeleroRestore.Status != nil { + if nar.Status.VeleroRestore.Status.Phase != "" { + return string(nar.Status.VeleroRestore.Status.Phase) + } + } + return "N/A" +} + +func getRestoreDuration(nar *nacv1alpha1.NonAdminRestore) string { + // Check if we have completion timestamp + if nar.Status.VeleroRestore != nil && nar.Status.VeleroRestore.Status != nil { + if !nar.Status.VeleroRestore.Status.CompletionTimestamp.IsZero() { + // Calculate duration from request creation to completion + duration := nar.Status.VeleroRestore.Status.CompletionTimestamp.Time.Sub(nar.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) + + days := int(duration.Hours() / 24) + hours := int(duration.Hours()) % 24 + minutes := int(duration.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd", days) + } else if hours > 0 { + return fmt.Sprintf("%dh", hours) + } else if minutes > 0 { + return fmt.Sprintf("%dm", minutes) + } else { + return "1m" + } +} diff --git a/cmd/non-admin/restore/logs.go b/cmd/non-admin/restore/logs.go new file mode 100644 index 0000000..76e4bc9 --- /dev/null +++ b/cmd/non-admin/restore/logs.go @@ -0,0 +1,145 @@ +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" + "net" + "time" + + "github.com/migtools/oadp-cli/cmd/shared" + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/client" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewLogsCommand(f client.Factory, use string) *cobra.Command { + var requestTimeout time.Duration + + c := &cobra.Command{ + Use: use + " NAME", + Short: "Show logs for a non-admin restore", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Get effective timeout (flag takes precedence over env var) + effectiveTimeout := shared.GetHTTPTimeoutWithOverride(requestTimeout) + + // Create context with the effective timeout for the entire operation + ctx, cancel := context.WithTimeout(context.Background(), effectiveTimeout) + 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) + } + // Set timeout on REST config to prevent hanging when cluster is unreachable + restConfig.Timeout = effectiveTimeout + + // Set a custom dial function with timeout to ensure TCP connection attempts + // also respect the timeout (the default TCP dial timeout is ~30s) + dialer := &net.Dialer{ + Timeout: effectiveTimeout, + } + restConfig.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, address) + } + + 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) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Waiting for restore logs to be processed (timeout: %v)...\n", effectiveTimeout) + + // Create download request and wait for signed URL + req, signedURL, err := shared.CreateAndWaitForDownloadURL(ctx, kbClient, shared.DownloadRequestOptions{ + BackupName: restoreName, + DataType: "RestoreLog", + Namespace: userNamespace, + Timeout: effectiveTimeout, + PollInterval: 2 * time.Second, + HTTPTimeout: effectiveTimeout, + OnProgress: func() { + fmt.Fprintf(cmd.OutOrStdout(), ".") + }, + }) + + if err != nil { + if req != nil { + // Clean up on error + if ctx.Err() == context.DeadlineExceeded { + return shared.FormatDownloadRequestTimeoutError(kbClient, req, effectiveTimeout) + } + deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelDelete() + _ = kbClient.Delete(deleteCtx, req) + } + return 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) + }() + + fmt.Fprintf(cmd.OutOrStdout(), "\nDownload URL received, fetching logs...\n") + + // Use the shared StreamDownloadContent function to download and stream logs + // Note: We use the same effective timeout for the HTTP download + if err := shared.StreamDownloadContentWithTimeout(signedURL, cmd.OutOrStdout(), effectiveTimeout); err != nil { + return fmt.Errorf("failed to download and stream logs: %w", err) + } + + return nil + }, + Example: ` kubectl oadp nonadmin restore logs my-restore + kubectl oadp nonadmin restore logs my-restore --request-timeout=30m`, + } + + c.Flags().DurationVar(&requestTimeout, "request-timeout", 0, fmt.Sprintf("The length of time to wait before giving up on a single server request (e.g., 30s, 5m, 1h). Overrides %s env var. Default: %v", shared.TimeoutEnvVar, shared.DefaultHTTPTimeout)) + + return c +} diff --git a/cmd/non-admin/restore/nonadminrestore_builder.go b/cmd/non-admin/restore/nonadminrestore_builder.go new file mode 100644 index 0000000..9cabfe3 --- /dev/null +++ b/cmd/non-admin/restore/nonadminrestore_builder.go @@ -0,0 +1,164 @@ +/* +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 = builder.ForNonAdminRestore("user-namespace", "restore-1"). + ObjectMeta( + builder.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 restore spec. +func (b *NonAdminRestoreBuilder) RestoreSpec(spec nacv1alpha1.NonAdminRestoreSpec) *NonAdminRestoreBuilder { + b.object.Spec = spec + return b +} + +// Phase sets the NonAdminRestore's phase. +func (b *NonAdminRestoreBuilder) Phase(phase nacv1alpha1.NonAdminPhase) *NonAdminRestoreBuilder { + b.object.Status.Phase = phase + return b +} + +// VeleroRestore sets the reference to the created Velero restore. +func (b *NonAdminRestoreBuilder) VeleroRestore(restoreName, restoreNamespace string) *NonAdminRestoreBuilder { + if b.object.Status.VeleroRestore == nil { + b.object.Status.VeleroRestore = &nacv1alpha1.VeleroRestore{} + } + b.object.Status.VeleroRestore.Name = restoreName + b.object.Status.VeleroRestore.Namespace = restoreNamespace + return b +} + +// Conditions sets the NonAdminRestore's conditions. +func (b *NonAdminRestoreBuilder) Conditions(conditions []metav1.Condition) *NonAdminRestoreBuilder { + b.object.Status.Conditions = conditions + return b +} + +// WithStatus sets the NonAdminRestore's status. +func (b *NonAdminRestoreBuilder) WithStatus(status nacv1alpha1.NonAdminRestoreStatus) *NonAdminRestoreBuilder { + b.object.Status = status + return b +} + +// ObjectMetaOpt is a functional option for setting ObjectMeta properties. +type ObjectMetaOpt func(obj metav1.Object) + +// WithLabels returns a functional option that sets labels on an object. +func WithLabels(key, value string) ObjectMetaOpt { + return func(obj metav1.Object) { + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels[key] = value + obj.SetLabels(labels) + } +} + +// WithLabelsMap returns a functional option that sets labels from a map on an object. +func WithLabelsMap(labels map[string]string) ObjectMetaOpt { + return func(obj metav1.Object) { + existingLabels := obj.GetLabels() + if existingLabels == nil { + existingLabels = make(map[string]string) + } + for k, v := range labels { + existingLabels[k] = v + } + obj.SetLabels(existingLabels) + } +} + +// WithAnnotations returns a functional option that sets annotations on an object. +func WithAnnotations(key, value string) ObjectMetaOpt { + return func(obj metav1.Object) { + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[key] = value + obj.SetAnnotations(annotations) + } +} + +// WithAnnotationsMap returns a functional option that sets annotations from a map on an object. +func WithAnnotationsMap(annotations map[string]string) ObjectMetaOpt { + return func(obj metav1.Object) { + existingAnnotations := obj.GetAnnotations() + if existingAnnotations == nil { + existingAnnotations = make(map[string]string) + } + for k, v := range annotations { + existingAnnotations[k] = v + } + obj.SetAnnotations(existingAnnotations) + } +} diff --git a/cmd/non-admin/restore/restore.go b/cmd/non-admin/restore/restore.go new file mode 100644 index 0000000..bababb2 --- /dev/null +++ b/cmd/non-admin/restore/restore.go @@ -0,0 +1,40 @@ +/* +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 ( + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/client" +) + +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"), + NewDescribeCommand(f, "describe"), + NewLogsCommand(f, "logs"), + 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..e820415 --- /dev/null +++ b/cmd/non-admin/restore/restore_test.go @@ -0,0 +1,522 @@ +/* +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", + "get", + "describe", + "logs", + "delete", + }, + }, + { + name: "nonadmin restore create help", + args: []string{"nonadmin", "restore", "create", "--help"}, + expectContains: []string{ + "Create a non-admin restore", + "--backup-name", + "--include-resources", + "--exclude-resources", + "--namespace-mappings", + }, + }, + { + name: "nonadmin restore get help", + args: []string{"nonadmin", "restore", "get", "--help"}, + expectContains: []string{ + "Get one or more non-admin restores", + }, + }, + { + name: "na restore shorthand help", + args: []string{"na", "restore", "--help"}, + expectContains: []string{ + "Work with non-admin restores", + "create", + "get", + "describe", + "logs", + "delete", + }, + }, + // Verb-noun order help command tests + { + name: "nonadmin get restore help", + args: []string{"nonadmin", "get", "restore", "--help"}, + expectContains: []string{ + "Get one or more non-admin resources", + "restore", + }, + }, + { + name: "nonadmin create restore help", + args: []string{"nonadmin", "create", "restore", "--help"}, + expectContains: []string{ + "Create non-admin resources", + "restore", + }, + }, + // Shorthand verb-noun order tests + { + name: "na get restore help", + args: []string{"na", "get", "restore", "--help"}, + expectContains: []string{ + "Get one or more non-admin resources", + "restore", + }, + }, + { + name: "na create restore help", + args: []string{"na", "create", "restore", "--help"}, + expectContains: []string{ + "Create non-admin resources", + "restore", + }, + }, + } + + 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", "get", "--help"}, + {"nonadmin", "restore", "get", "-h"}, + {"nonadmin", "restore", "describe", "--help"}, + {"nonadmin", "restore", "describe", "-h"}, + {"nonadmin", "restore", "logs", "--help"}, + {"nonadmin", "restore", "logs", "-h"}, + {"nonadmin", "restore", "delete", "--help"}, + {"nonadmin", "restore", "delete", "-h"}, + {"na", "restore", "--help"}, + {"na", "restore", "-h"}, + // Verb-noun order help flags + {"nonadmin", "get", "restore", "--help"}, + {"nonadmin", "get", "restore", "-h"}, + {"nonadmin", "create", "restore", "--help"}, + {"nonadmin", "create", "restore", "-h"}, + {"nonadmin", "describe", "restore", "--help"}, + {"nonadmin", "describe", "restore", "-h"}, + {"nonadmin", "logs", "restore", "--help"}, + {"nonadmin", "logs", "restore", "-h"}, + {"nonadmin", "delete", "restore", "--help"}, + {"nonadmin", "delete", "restore", "-h"}, + // Shorthand verb-noun order help flags + {"na", "get", "restore", "--help"}, + {"na", "get", "restore", "-h"}, + {"na", "create", "restore", "--help"}, + {"na", "create", "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{ + "--backup-name", + "--include-resources", + "--exclude-resources", + "--namespace-mappings", + "--labels", + "--annotations", + "--restore-volumes", + "--selector", + "--or-selector", + "--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", + "--backup-name", + "--namespace-mappings", + "--include-resources", + } + + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "create", "--help"}, + expectedExamples) + }) + + t.Run("main restore help shows subcommands", func(t *testing.T) { + expectedSubcommands := []string{ + "create", + "get", + "describe", + "logs", + "delete", + } + + 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", "logs", "--help"}, + {"nonadmin", "restore", "delete", "--help"}, + {"na", "restore", "get", "--help"}, + // Verb-noun order commands + {"nonadmin", "get", "restore", "--help"}, + {"nonadmin", "create", "restore", "--help"}, + {"nonadmin", "describe", "restore", "--help"}, + {"nonadmin", "logs", "restore", "--help"}, + {"nonadmin", "delete", "restore", "--help"}, + {"na", "get", "restore", "--help"}, + {"na", "create", "restore", "--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}) + } + }) +} + +// TestVerbNounOrderRestoreExamples tests that verb-noun order commands show proper examples +func TestVerbNounOrderRestoreExamples(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("verb commands show proper examples", func(t *testing.T) { + // Test that verb commands show examples with kubectl oadp prefix + expectedExamples := []string{ + "kubectl oadp nonadmin get restore", + "kubectl oadp nonadmin create restore", + "kubectl oadp nonadmin describe restore", + "kubectl oadp nonadmin logs restore", + "kubectl oadp nonadmin delete restore", + } + + commands := [][]string{ + {"nonadmin", "get", "--help"}, + {"nonadmin", "create", "--help"}, + {"nonadmin", "describe", "--help"}, + {"nonadmin", "logs", "--help"}, + {"nonadmin", "delete", "--help"}, + } + + for i, cmd := range commands { + testutil.TestHelpCommand(t, binaryPath, cmd, []string{expectedExamples[i]}) + } + }) + + t.Run("verb commands with specific resources show proper examples", func(t *testing.T) { + // Test that verb commands with specific resources show examples + expectedExamples := []string{ + "kubectl oadp nonadmin get restore my-restore", + "kubectl oadp nonadmin create restore my-restore", + "kubectl oadp nonadmin describe restore my-restore", + "kubectl oadp nonadmin logs restore my-restore", + "kubectl oadp nonadmin delete restore my-restore", + } + + commands := [][]string{ + {"nonadmin", "get", "restore", "--help"}, + {"nonadmin", "create", "restore", "--help"}, + {"nonadmin", "describe", "restore", "--help"}, + {"nonadmin", "logs", "restore", "--help"}, + {"nonadmin", "delete", "restore", "--help"}, + } + + for i, cmd := range commands { + testutil.TestHelpCommand(t, binaryPath, cmd, []string{expectedExamples[i]}) + } + }) + + t.Run("shorthand verb commands show proper examples", func(t *testing.T) { + // Test that shorthand verb commands show examples + expectedExamples := []string{ + "kubectl oadp nonadmin get restore", + "kubectl oadp nonadmin create restore", + "kubectl oadp nonadmin describe restore", + "kubectl oadp nonadmin logs restore", + "kubectl oadp nonadmin delete restore", + } + + commands := [][]string{ + {"na", "get", "--help"}, + {"na", "create", "--help"}, + {"na", "describe", "--help"}, + {"na", "logs", "--help"}, + {"na", "delete", "--help"}, + } + + for i, cmd := range commands { + testutil.TestHelpCommand(t, binaryPath, cmd, []string{expectedExamples[i]}) + } + }) +} + +// TestNonAdminRestoreCreateRequiresBackupName tests that create requires --backup-name +func TestNonAdminRestoreCreateRequiresBackupName(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("create help shows --backup-name as required", func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "create", "--help"}, + []string{"--backup-name", "required"}) + }) +} + +// TestNonAdminRestoreDescribeCommands tests describe command functionality +func TestNonAdminRestoreDescribeCommands(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + tests := []struct { + name string + args []string + expectContains []string + }{ + { + name: "nonadmin restore describe help", + args: []string{"nonadmin", "restore", "describe", "--help"}, + expectContains: []string{ + "Describe a non-admin restore", + "--details", + "--request-timeout", + }, + }, + { + name: "nonadmin describe restore help - verb-noun order", + args: []string{"nonadmin", "describe", "restore", "--help"}, + expectContains: []string{ + "Describe non-admin resources", + "restore", + }, + }, + { + name: "na describe restore help - shorthand", + args: []string{"na", "describe", "restore", "--help"}, + expectContains: []string{ + "Describe non-admin resources", + "restore", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, tt.args, tt.expectContains) + }) + } +} + +// TestNonAdminRestoreLogsCommands tests logs command functionality +func TestNonAdminRestoreLogsCommands(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + tests := []struct { + name string + args []string + expectContains []string + }{ + { + name: "nonadmin restore logs help", + args: []string{"nonadmin", "restore", "logs", "--help"}, + expectContains: []string{ + "Show logs for a non-admin restore", + "--request-timeout", + }, + }, + { + name: "nonadmin logs restore help - verb-noun order", + args: []string{"nonadmin", "logs", "restore", "--help"}, + expectContains: []string{ + "Get logs for non-admin resources", + "restore", + }, + }, + { + name: "na logs restore help - shorthand", + args: []string{"na", "logs", "restore", "--help"}, + expectContains: []string{ + "Get logs for non-admin resources", + "restore", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, tt.args, tt.expectContains) + }) + } +} + +// TestNonAdminRestoreDeleteCommands tests delete command functionality +func TestNonAdminRestoreDeleteCommands(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + tests := []struct { + name string + args []string + expectContains []string + }{ + { + name: "nonadmin restore delete help", + args: []string{"nonadmin", "restore", "delete", "--help"}, + expectContains: []string{ + "Delete one or more non-admin restores", + "--confirm", + "--all", + }, + }, + { + name: "nonadmin delete restore help - verb-noun order", + args: []string{"nonadmin", "delete", "restore", "--help"}, + expectContains: []string{ + "Delete non-admin resources", + "restore", + }, + }, + { + name: "na delete restore help - shorthand", + args: []string{"na", "delete", "restore", "--help"}, + expectContains: []string{ + "Delete non-admin resources", + "restore", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, tt.args, tt.expectContains) + }) + } +} + +// TestNonAdminRestoreDeleteAllFlag tests --all flag behavior +func TestNonAdminRestoreDeleteAllFlag(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + t.Run("delete help shows --all flag", func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "delete", "--help"}, + []string{"--all", "Delete all restores"}) + }) + + t.Run("delete help shows --confirm flag", func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "delete", "--help"}, + []string{"--confirm", "Skip confirmation"}) + }) +} diff --git a/cmd/non-admin/verbs/registry.go b/cmd/non-admin/verbs/registry.go index 694f389..f03d1e0 100644 --- a/cmd/non-admin/verbs/registry.go +++ b/cmd/non-admin/verbs/registry.go @@ -19,6 +19,7 @@ package verbs 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" ) @@ -35,6 +36,21 @@ func RegisterBackupResources(builder *NonAdminVerbBuilder, verb string) { }) } +// RegisterRestoreResources registers restore resource for a specific verb +func RegisterRestoreResources(builder *NonAdminVerbBuilder, verb string) { + // Only register restore for supported verbs: create, get, describe, logs, delete + if verb == "create" || verb == "get" || verb == "describe" || verb == "logs" || verb == "delete" { + builder.RegisterResource("restore", NonAdminResourceHandler{ + GetCommandFunc: func(factory client.Factory) *cobra.Command { + return restore.NewRestoreCommand(factory) + }, + GetSubCommandFunc: func(resourceCmd *cobra.Command) *cobra.Command { + return getSubCommand(resourceCmd, verb) + }, + }) + } +} + // RegisterBSLResources registers bsl resource for a specific verb func RegisterBSLResources(builder *NonAdminVerbBuilder, verb string) { // Only register BSL for supported verbs: create, get diff --git a/cmd/non-admin/verbs/verbs.go b/cmd/non-admin/verbs/verbs.go index a821317..bb51085 100644 --- a/cmd/non-admin/verbs/verbs.go +++ b/cmd/non-admin/verbs/verbs.go @@ -25,6 +25,7 @@ import ( func NewGetCommand(factory client.Factory) *cobra.Command { builder := NewNonAdminVerbBuilder(factory) RegisterBackupResources(builder, "get") + RegisterRestoreResources(builder, "get") RegisterBSLResources(builder, "get") return builder.BuildVerbCommand(NonAdminVerbConfig{ @@ -37,6 +38,12 @@ func NewGetCommand(factory client.Factory) *cobra.Command { # Get a specific non-admin backup kubectl oadp nonadmin get backup my-backup + # Get all non-admin restores + kubectl oadp nonadmin get restore + + # Get a specific non-admin restore + kubectl oadp nonadmin get restore my-restore + # Get all non-admin backup storage locations kubectl oadp nonadmin get bsl @@ -49,6 +56,7 @@ func NewGetCommand(factory client.Factory) *cobra.Command { func NewCreateCommand(factory client.Factory) *cobra.Command { builder := NewNonAdminVerbBuilder(factory) RegisterBackupResources(builder, "create") + RegisterRestoreResources(builder, "create") RegisterBSLResources(builder, "create") return builder.BuildVerbCommand(NonAdminVerbConfig{ @@ -58,6 +66,9 @@ func NewCreateCommand(factory client.Factory) *cobra.Command { Example: ` # Create a non-admin backup kubectl oadp nonadmin create backup my-backup + # Create a non-admin restore + kubectl oadp nonadmin create restore my-restore --backup-name my-backup + # Create a backup storage location kubectl oadp nonadmin create bsl my-bsl`, }) @@ -67,6 +78,7 @@ func NewCreateCommand(factory client.Factory) *cobra.Command { func NewDeleteCommand(factory client.Factory) *cobra.Command { builder := NewNonAdminVerbBuilder(factory) RegisterBackupResources(builder, "delete") + RegisterRestoreResources(builder, "delete") RegisterBSLResources(builder, "delete") return builder.BuildVerbCommand(NonAdminVerbConfig{ @@ -74,7 +86,10 @@ func NewDeleteCommand(factory client.Factory) *cobra.Command { Short: "Delete non-admin resources", Long: "Delete non-admin resources. This is a verb-based command that delegates to the appropriate noun command.", Example: ` # Delete a non-admin backup - kubectl oadp nonadmin delete backup my-backup`, + kubectl oadp nonadmin delete backup my-backup + + # Delete a non-admin restore + kubectl oadp nonadmin delete restore my-restore`, }) } @@ -82,6 +97,7 @@ func NewDeleteCommand(factory client.Factory) *cobra.Command { func NewDescribeCommand(factory client.Factory) *cobra.Command { builder := NewNonAdminVerbBuilder(factory) RegisterBackupResources(builder, "describe") + RegisterRestoreResources(builder, "describe") RegisterBSLResources(builder, "describe") return builder.BuildVerbCommand(NonAdminVerbConfig{ @@ -89,7 +105,10 @@ func NewDescribeCommand(factory client.Factory) *cobra.Command { Short: "Describe non-admin resources", Long: "Describe non-admin resources. This is a verb-based command that delegates to the appropriate noun command.", Example: ` # Describe a non-admin backup - kubectl oadp nonadmin describe backup my-backup`, + kubectl oadp nonadmin describe backup my-backup + + # Describe a non-admin restore + kubectl oadp nonadmin describe restore my-restore`, }) } @@ -97,6 +116,7 @@ func NewDescribeCommand(factory client.Factory) *cobra.Command { func NewLogsCommand(factory client.Factory) *cobra.Command { builder := NewNonAdminVerbBuilder(factory) RegisterBackupResources(builder, "logs") + RegisterRestoreResources(builder, "logs") RegisterBSLResources(builder, "logs") return builder.BuildVerbCommand(NonAdminVerbConfig{ @@ -104,6 +124,9 @@ func NewLogsCommand(factory client.Factory) *cobra.Command { Short: "Get logs for non-admin resources", Long: "Get logs for non-admin resources. This is a verb-based command that delegates to the appropriate noun command.", Example: ` # Get logs for a non-admin backup - kubectl oadp nonadmin logs backup my-backup`, + kubectl oadp nonadmin logs backup my-backup + + # Get logs for a non-admin restore + kubectl oadp nonadmin logs restore my-restore`, }) }