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/namespace_flag_test.go b/cmd/namespace_flag_test.go new file mode 100644 index 0000000..2fe4a53 --- /dev/null +++ b/cmd/namespace_flag_test.go @@ -0,0 +1,76 @@ +/* +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 cmd + +import ( + "testing" + + "github.com/migtools/oadp-cli/internal/testutil" +) + +// TestNamespaceFlag tests that the -n/--namespace flag is available for admin commands +func TestNamespaceFlag(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + tests := []struct { + name string + args []string + expectContains []string + }{ + { + name: "backup help shows namespace flag", + args: []string{"backup", "create", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "restore help shows namespace flag", + args: []string{"restore", "create", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "schedule help shows namespace flag", + args: []string{"schedule", "create", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "nabsl-request get help shows namespace flag", + args: []string{"nabsl-request", "get", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "nabsl-request approve help shows namespace flag", + args: []string{"nabsl-request", "approve", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "nabsl-request reject help shows namespace flag", + args: []string{"nabsl-request", "reject", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + { + name: "nabsl-request describe help shows namespace flag", + args: []string{"nabsl-request", "describe", "--help"}, + expectContains: []string{"-n, --namespace"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, tt.args, tt.expectContains) + }) + } +} diff --git a/cmd/non-admin/backup/README.md b/cmd/non-admin/backup/README.md new file mode 100644 index 0000000..5ba9f9c --- /dev/null +++ b/cmd/non-admin/backup/README.md @@ -0,0 +1,147 @@ +# NonAdminBackup Create Command + +## Overview + +The `nonadmin backup create` command creates backup requests for non-admin users within their authorized namespaces. + +## Minimal MVP Flags + +The following flags represent the minimal viable product for backup creation: + +### Resource Filtering + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--include-resources` | StringArray | `["*"]` | Resources to include | ✅ MVP | +| `--exclude-resources` | StringArray | - | Resources to exclude | ✅ MVP | + +### Label Selection + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--selector`, `-l` | LabelSelector | - | Label selector filter | ✅ MVP | +| `--or-selector` | OrLabelSelector | - | OR label selectors | ✅ MVP | + +### Cluster Resources + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--include-cluster-resources` | OptionalBool | - | Include cluster resources (users can only set to false) | ✅ MVP | + +### Timing & Storage + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--ttl` | Duration | - | Backup retention time | ✅ MVP | +| `--storage-location` | String | - | NABSL reference | ✅ MVP | +| `--csi-snapshot-timeout` | Duration | - | CSI snapshot timeout | ✅ MVP | +| `--item-operation-timeout` | Duration | - | Async operation timeout | ✅ MVP | + +### Snapshot Control + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--snapshot-volumes` | OptionalBool | - | Enable volume snapshots | ✅ MVP | +| `--snapshot-move-data` | OptionalBool | - | Move snapshot data | ✅ MVP | +| `--default-volumes-to-fs-backup` | OptionalBool | - | Use filesystem backup | ✅ MVP | + +### Control Flags + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--force`, `-f` | Boolean | false | Skip storage-location requirement | ✅ MVP | + +## Restricted Flags (Not Available) + +The following flags are **restricted** for non-admin users per the NAB API restrictions: + +| Flag | Reason | Doc Reference | +|------|--------|---------------| +| `--include-namespaces` | Restricted - automatically set to current namespace | NAB API docs | +| `--exclude-namespaces` | Restricted for non-admin users | NAB API docs | +| `--include-cluster-scoped-resources` | Restricted - only empty list acceptable | NAB API docs | +| `--volume-snapshot-locations` | Not supported - defaults used | NAB API docs | + +## Flags Not in MVP (Future Enhancements) + +The following flags are **allowed by the API** but not included in the minimal MVP: + +### Metadata +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--labels` | ✅ Yes | Future | +| `--annotations` | ✅ Yes | Future | + +### Advanced Features +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--from-schedule` | N/A | Future (requires schedule API) | +| `--ordered-resources` | ✅ Yes | Future | +| `--data-mover` | ✅ Yes | Future | +| `--resource-policies-configmap` | ✅ Yes | Future (admin-created only) | +| `--parallel-files-upload` | ✅ Yes | Future | + +### Scoped Resources +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--exclude-cluster-scoped-resources` | ✅ Yes | Future | +| `--include-namespace-scoped-resources` | ✅ Yes | Future | +| `--exclude-namespace-scoped-resources` | ✅ Yes | Future | + +## Examples + +```bash +# Create a simple backup of all resources in the current namespace +oadp nonadmin backup create my-backup + +# Create backup with specific resources +oadp nonadmin backup create my-backup \ + --include-resources deployments,services + +# Create backup with label selector +oadp nonadmin backup create my-backup \ + --selector app=myapp + +# Create backup with snapshots and TTL +oadp nonadmin backup create my-backup \ + --snapshot-volumes \ + --ttl 720h + +# Create backup with specific storage location +oadp nonadmin backup create my-backup \ + --storage-location my-nabsl + +# Force creation with admin defaults (interactive confirmation) +oadp nonadmin backup create my-backup --force +``` + +## Architecture Notes + +The backup create command uses **struct embedding** from Velero's backup CreateOptions, matching the pattern used in `nonadmin restore create`. This approach: +- Reduces code duplication +- Ensures compatibility with Velero updates +- Uses BindFlags() as the control gate to expose only MVP features to non-admin users +- Maintains forward compatibility for future enhancements + +## Implementation Details + +### Struct Embedding Pattern + +```go +type CreateOptions struct { + *velerobackup.CreateOptions // Embed Velero's CreateOptions + + // NAB-specific fields + Name string + Force bool + client kbclient.WithWatch + currentNamespace string +} +``` + +### MVP Flag Control + +The `BindFlags()` method acts as a control gate, exposing only the MVP flags while the embedded struct contains all Velero options. This allows: +- Easy addition of new flags in the future (just bind them in BindFlags) +- Automatic compatibility with Velero struct updates +- Clear separation between what's exposed vs what's available diff --git a/cmd/non-admin/backup/backup_test.go b/cmd/non-admin/backup/backup_test.go index 78493aa..10178ff 100644 --- a/cmd/non-admin/backup/backup_test.go +++ b/cmd/non-admin/backup/backup_test.go @@ -52,7 +52,6 @@ func TestNonAdminBackupCommands(t *testing.T) { "--include-resources", "--exclude-resources", "--force", - "--assume-yes", }, }, { @@ -243,19 +242,21 @@ func TestNonAdminBackupHelpFlags(t *testing.T) { func TestNonAdminBackupCreateFlags(t *testing.T) { binaryPath := testutil.BuildCLIBinary(t) - t.Run("create command has all expected flags", func(t *testing.T) { + t.Run("create command has all expected MVP flags", func(t *testing.T) { expectedFlags := []string{ - "--storage-location", "--include-resources", "--exclude-resources", - "--labels", - "--annotations", - "--force", - "--assume-yes", - "--snapshot-volumes", - "--ttl", "--selector", "--or-selector", + "--include-cluster-resources", + "--ttl", + "--storage-location", + "--csi-snapshot-timeout", + "--item-operation-timeout", + "--snapshot-volumes", + "--snapshot-move-data", + "--default-volumes-to-fs-backup", + "--force", } testutil.TestHelpCommand(t, binaryPath, diff --git a/cmd/non-admin/backup/create.go b/cmd/non-admin/backup/create.go index 44d1f4d..32fb3dd 100644 --- a/cmd/non-admin/backup/create.go +++ b/cmd/non-admin/backup/create.go @@ -22,7 +22,6 @@ import ( "fmt" "os" "strings" - "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -34,7 +33,7 @@ import ( "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" + velerobackup "github.com/vmware-tanzu/velero/pkg/cmd/cli/backup" "github.com/vmware-tanzu/velero/pkg/cmd/util/output" ) @@ -44,33 +43,32 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command { c := &cobra.Command{ Use: use + " NAME", Short: "Create a non-admin backup", - Args: cobra.MaximumNArgs(1), + Args: cobra.ExactArgs(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 backup containing all resources in the current namespace. - kubectl oadp nonadmin backup create backup1 --storage-location my-nabsl + Example: ` # Create a simple backup of all resources in the current namespace. + kubectl oadp nonadmin backup create backup1 - # Create a non-admin backup with specific resource types. - kubectl oadp nonadmin backup create backup2 --include-resources deployments,services --storage-location my-nabsl + # Create a backup with specific resource types. + kubectl oadp nonadmin backup create backup2 --include-resources deployments,services - # Create a non-admin backup excluding certain resources. - kubectl oadp nonadmin backup create backup3 --exclude-resources secrets --storage-location my-nabsl + # Create a backup with label selector. + kubectl oadp nonadmin backup create backup3 --selector app=myapp - # Force creation with admin defaults (no storage location specified). - kubectl oadp nonadmin backup create backup4 --force + # Create a backup with snapshots and TTL. + kubectl oadp nonadmin backup create backup4 --snapshot-volumes --ttl 720h - # Force creation with admin defaults non-interactively. - kubectl oadp nonadmin backup create backup5 --force --assume-yes + # Create a backup with specific storage location. + kubectl oadp nonadmin backup create backup5 --storage-location my-nabsl - # View the YAML for a non-admin backup that doesn't snapshot volumes, without sending it to the server. - kubectl oadp nonadmin backup create backup6 --snapshot-volumes=false --storage-location my-nabsl -o yaml`, + # View the YAML for a backup without sending it to the server. + kubectl oadp nonadmin backup create backup6 -o yaml`, } o.BindFlags(c.Flags()) - o.BindFromSchedule(c.Flags()) output.BindFlags(c.Flags()) output.ClearOutputFlagDefault(c) @@ -78,89 +76,53 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command { } type CreateOptions struct { - Name string - TTL time.Duration - SnapshotVolumes flag.OptionalBool - SnapshotMoveData flag.OptionalBool - DataMover string - DefaultVolumesToFsBackup flag.OptionalBool - IncludeResources flag.StringArray - ExcludeResources flag.StringArray - IncludeClusterScopedResources flag.StringArray - ExcludeClusterScopedResources flag.StringArray - IncludeNamespaceScopedResources flag.StringArray - ExcludeNamespaceScopedResources flag.StringArray - Labels flag.Map - Annotations flag.Map - Selector flag.LabelSelector - OrSelector flag.OrLabelSelector - IncludeClusterResources flag.OptionalBool - StorageLocation string - SnapshotLocations []string - FromSchedule string - OrderedResources string - CSISnapshotTimeout time.Duration - ItemOperationTimeout time.Duration - ResPoliciesConfigmap string - Force bool - AssumeYes bool - client kbclient.WithWatch - ParallelFilesUpload int - currentNamespace string + *velerobackup.CreateOptions // Embed Velero's CreateOptions + + // NAB-specific fields + Name string // The NonAdminBackup resource name (maps to Velero's BackupName) + Force bool // NAB-specific: bypass storage-location requirement + client kbclient.WithWatch + currentNamespace string } func NewCreateOptions() *CreateOptions { return &CreateOptions{ - IncludeResources: flag.NewStringArray("*"), - Labels: flag.NewMap(), - Annotations: flag.NewMap(), - SnapshotVolumes: flag.NewOptionalBool(nil), - IncludeClusterResources: flag.NewOptionalBool(nil), + CreateOptions: velerobackup.NewCreateOptions(), + Force: false, } } func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { - flags.DurationVar(&o.TTL, "ttl", o.TTL, "How long before the backup can be garbage collected.") - flags.Var(&o.IncludeResources, "include-resources", "Resources to include in the backup, formatted as resource.group, such as storageclasses.storage.k8s.io (use '*' for all resources). Cannot work with include-cluster-scoped-resources, exclude-cluster-scoped-resources, include-namespace-scoped-resources and exclude-namespace-scoped-resources.") - flags.Var(&o.ExcludeResources, "exclude-resources", "Resources to exclude from the backup, formatted as resource.group, such as storageclasses.storage.k8s.io. Cannot work with include-cluster-scoped-resources, exclude-cluster-scoped-resources, include-namespace-scoped-resources and exclude-namespace-scoped-resources.") - flags.Var(&o.IncludeClusterScopedResources, "include-cluster-scoped-resources", "Cluster-scoped resources to include in the backup, formatted as resource.group, such as storageclasses.storage.k8s.io(use '*' for all resources). Cannot work with include-resources, exclude-resources and include-cluster-resources.") - flags.Var(&o.ExcludeClusterScopedResources, "exclude-cluster-scoped-resources", "Cluster-scoped resources to exclude from the backup, formatted as resource.group, such as storageclasses.storage.k8s.io(use '*' for all resources). Cannot work with include-resources, exclude-resources and include-cluster-resources.") - flags.Var(&o.IncludeNamespaceScopedResources, "include-namespace-scoped-resources", "Namespaced resources to include in the backup, formatted as resource.group, such as deployments.apps(use '*' for all resources). Cannot work with include-resources, exclude-resources and include-cluster-resources.") - flags.Var(&o.ExcludeNamespaceScopedResources, "exclude-namespace-scoped-resources", "Namespaced resources to exclude from the backup, formatted as resource.group, such as deployments.apps(use '*' for all resources). Cannot work with include-resources, exclude-resources and include-cluster-resources.") - flags.Var(&o.Labels, "labels", "Labels to apply to the backup.") - flags.Var(&o.Annotations, "annotations", "Annotations to apply to the backup.") - flags.StringVar(&o.StorageLocation, "storage-location", "", "Location in which to store the backup.") - flags.StringSliceVar(&o.SnapshotLocations, "volume-snapshot-locations", o.SnapshotLocations, "List of locations (at most one per provider) where volume snapshots should be stored.") + // Resource filtering (MVP) + flags.Var(&o.IncludeResources, "include-resources", "Resources to include in the backup, 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 backup, formatted as resource.group, such as storageclasses.storage.k8s.io.") + + // Label selection (MVP) flags.VarP(&o.Selector, "selector", "l", "Only back up resources matching this label selector.") flags.Var(&o.OrSelector, "or-selector", "Backup 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.OrderedResources, "ordered-resources", "", "Mapping Kinds to an ordered list of specific resources of that Kind. Resource names are separated by commas and their names are in format 'namespace/resourcename'. For cluster scope resource, simply use resource name. Key-value pairs in the mapping are separated by semi-colon. Example: 'pods=ns1/pod1,ns1/pod2;persistentvolumeclaims=ns1/pvc4,ns1/pvc8'. Optional.") + + // Cluster resources (MVP) + f := flags.VarPF(&o.IncludeClusterResources, "include-cluster-resources", "", "Include cluster-scoped resources in the backup.") + f.NoOptDefVal = cmd.TRUE + + // Timing/Storage (MVP) + flags.DurationVar(&o.TTL, "ttl", o.TTL, "How long before the backup can be garbage collected.") + flags.StringVar(&o.StorageLocation, "storage-location", "", "Location in which to store the backup.") flags.DurationVar(&o.CSISnapshotTimeout, "csi-snapshot-timeout", o.CSISnapshotTimeout, "How long to wait for CSI snapshot creation before timeout.") flags.DurationVar(&o.ItemOperationTimeout, "item-operation-timeout", o.ItemOperationTimeout, "How long to wait for async plugin operations before timeout.") - f := flags.VarPF(&o.SnapshotVolumes, "snapshot-volumes", "", "Take snapshots of PersistentVolumes as part of the backup. If the parameter is not set, it is treated as setting to 'true'.") - // this allows the user to just specify "--snapshot-volumes" as shorthand for "--snapshot-volumes=true" - // like a normal bool flag - f.NoOptDefVal = cmd.TRUE - f = flags.VarPF(&o.SnapshotMoveData, "snapshot-move-data", "", "Specify whether snapshot data should be moved") + // Snapshot control (MVP) + f = flags.VarPF(&o.SnapshotVolumes, "snapshot-volumes", "", "Take snapshots of PersistentVolumes as part of the backup.") f.NoOptDefVal = cmd.TRUE - f = flags.VarPF(&o.IncludeClusterResources, "include-cluster-resources", "", "Include cluster-scoped resources in the backup. Cannot work with include-cluster-scoped-resources, exclude-cluster-scoped-resources, include-namespace-scoped-resources and exclude-namespace-scoped-resources.") + f = flags.VarPF(&o.SnapshotMoveData, "snapshot-move-data", "", "Specify whether snapshot data should be moved.") f.NoOptDefVal = cmd.TRUE - f = flags.VarPF(&o.DefaultVolumesToFsBackup, "default-volumes-to-fs-backup", "", "Use pod volume file system backup by default for volumes") + f = flags.VarPF(&o.DefaultVolumesToFsBackup, "default-volumes-to-fs-backup", "", "Use pod volume file system backup by default for volumes.") f.NoOptDefVal = cmd.TRUE - flags.StringVar(&o.ResPoliciesConfigmap, "resource-policies-configmap", "", "Reference to the resource policies configmap that backup should use") - flags.StringVar(&o.DataMover, "data-mover", "", "Specify the data mover to be used by the backup. If the parameter is not set or set as 'velero', the built-in data mover will be used") - flags.IntVar(&o.ParallelFilesUpload, "parallel-files-upload", 0, "Number of files uploads simultaneously when running a backup. This is only applicable for the kopia uploader") + // NAB-specific control flag flags.BoolVarP(&o.Force, "force", "f", o.Force, "Force creation without specifying a storage location (uses admin defaults).") - flags.BoolVarP(&o.AssumeYes, "assume-yes", "y", o.AssumeYes, "Assume yes to all prompts and run non-interactively.") -} - -// BindFromSchedule binds the from-schedule flag separately so it is not called -// by other create commands that reuse CreateOptions's BindFlags method. -func (o *CreateOptions) BindFromSchedule(flags *pflag.FlagSet) { - flags.StringVar(&o.FromSchedule, "from-schedule", "", "Create a backup from the template of an existing schedule. Cannot be used with any other filters. Backup name is optional if used.") } func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { @@ -168,29 +130,15 @@ func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Facto return err } - 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") - } - - // Ensure if FromSchedule is set, it has a non-empty value - if err := o.validateFromScheduleFlag(c); err != nil { - return err - } - - // Ensure that unless FromSchedule is set, args contains a backup name - if o.FromSchedule == "" && len(args) != 1 { - return fmt.Errorf("a backup name is required, unless you are creating based on a schedule") + if len(args) != 1 { + return fmt.Errorf("a backup name is required") } - if o.oldAndNewFilterParametersUsedTogether() { - return fmt.Errorf("include-resources, exclude-resources and include-cluster-resources are old filter parameters.\n" + - "include-cluster-scoped-resources, exclude-cluster-scoped-resources, include-namespace-scoped-resources and exclude-namespace-scoped-resources are new filter parameters.\n" + - "They cannot be used together") + 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") } - // Note: Storage location and snapshot location validation removed for NonAdminBackup - // as these are typically managed by the underlying Velero backup resource - + // Storage location validation if !o.Force && o.StorageLocation == "" { return fmt.Errorf("a valid NonAdminBackupStorageLocation must be provided via --storage-location, or use --force to create with admin defaults") } @@ -198,22 +146,8 @@ func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Facto return nil } -func (o *CreateOptions) validateFromScheduleFlag(c *cobra.Command) error { - trimmed := strings.TrimSpace(o.FromSchedule) - if c.Flags().Changed("from-schedule") && trimmed == "" { - return fmt.Errorf("flag must have a non-empty value: --from-schedule") - } - - // Assign the trimmed value back - o.FromSchedule = trimmed - return nil -} - 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] - } + o.Name = args[0] // Create client with NonAdmin scheme client, err := shared.NewClientWithScheme(f, shared.ClientOptions{ @@ -244,10 +178,6 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { return err } - if o.FromSchedule != "" { - fmt.Println("Creating non-admin backup from schedule, all other filters are ignored.") - } - // Prompt for confirmation if using force without storage location if err := o.promptForceConfirmation(); err != nil { return err @@ -263,38 +193,8 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { return nil } -// ParseOrderedResources converts to map of Kinds to an ordered list of specific resources of that Kind. -// Resource names in the list are in format 'namespace/resourcename' and separated by commas. -// Key-value pairs in the mapping are separated by semi-colon. -// Ex: 'pods=ns1/pod1,ns1/pod2;persistentvolumeclaims=ns1/pvc4,ns1/pvc8'. -func ParseOrderedResources(orderMapStr string) (map[string]string, error) { - entries := strings.Split(orderMapStr, ";") - if len(entries) == 0 { - return nil, fmt.Errorf("invalid OrderedResources '%s'", orderMapStr) - } - orderedResources := make(map[string]string) - for _, entry := range entries { - kv := strings.Split(entry, "=") - if len(kv) != 2 { - return nil, fmt.Errorf("invalid OrderedResources '%s'", entry) - } - kind := strings.TrimSpace(kv[0]) - order := strings.TrimSpace(kv[1]) - orderedResources[kind] = order - } - return orderedResources, nil -} - func (o *CreateOptions) BuildNonAdminBackup(namespace string) (*nacv1alpha1.NonAdminBackup, error) { - var backupSpec *velerov1api.BackupSpec - var err error - - if o.FromSchedule != "" { - backupSpec, err = o.buildBackupSpecFromSchedule(namespace) - } else { - backupSpec, err = o.buildBackupSpecFromOptions(namespace) - } - + backupSpec, err := o.buildBackupSpecFromOptions(namespace) if err != nil { return nil, err } @@ -302,61 +202,30 @@ func (o *CreateOptions) BuildNonAdminBackup(namespace string) (*nacv1alpha1.NonA return o.createNonAdminBackup(namespace, backupSpec), nil } -// buildBackupSpecFromSchedule creates a BackupSpec from an existing schedule -func (o *CreateOptions) buildBackupSpecFromSchedule(namespace string) (*velerov1api.BackupSpec, error) { - schedule := new(velerov1api.Schedule) - err := o.client.Get(context.TODO(), kbclient.ObjectKey{Namespace: namespace, Name: o.FromSchedule}, schedule) - if err != nil { - return nil, err - } - - if o.Name == "" { - o.Name = schedule.TimestampedName(time.Now().UTC()) - } - - return &schedule.Spec.Template, nil -} - // buildBackupSpecFromOptions creates a BackupSpec from command line options func (o *CreateOptions) buildBackupSpecFromOptions(namespace string) (*velerov1api.BackupSpec, error) { backupBuilder := builder.ForBackup(namespace, o.Name). IncludedNamespaces(namespace). // Automatically include the current namespace IncludedResources(o.IncludeResources...). ExcludedResources(o.ExcludeResources...). - IncludedClusterScopedResources(o.IncludeClusterScopedResources...). - ExcludedClusterScopedResources(o.ExcludeClusterScopedResources...). - IncludedNamespaceScopedResources(o.IncludeNamespaceScopedResources...). - ExcludedNamespaceScopedResources(o.ExcludeNamespaceScopedResources...). LabelSelector(o.Selector.LabelSelector). OrLabelSelector(o.OrSelector.OrLabelSelectors). TTL(o.TTL). StorageLocation(o.StorageLocation). - VolumeSnapshotLocations(o.SnapshotLocations...). CSISnapshotTimeout(o.CSISnapshotTimeout). - ItemOperationTimeout(o.ItemOperationTimeout). - DataMover(o.DataMover) + ItemOperationTimeout(o.ItemOperationTimeout) if err := o.applyOptionalBackupOptions(backupBuilder); err != nil { return nil, err } - tempBackup := backupBuilder. - ObjectMeta(builder.WithLabelsMap(o.Labels.Data()), builder.WithAnnotationsMap(o.Annotations.Data())). - Result() + tempBackup := backupBuilder.Result() return &tempBackup.Spec, nil } // applyOptionalBackupOptions applies optional flags to the backup builder func (o *CreateOptions) applyOptionalBackupOptions(backupBuilder *builder.BackupBuilder) error { - if len(o.OrderedResources) > 0 { - orders, err := ParseOrderedResources(o.OrderedResources) - if err != nil { - return err - } - backupBuilder.OrderedResources(orders) - } - if o.SnapshotVolumes.Value != nil { backupBuilder.SnapshotVolumes(*o.SnapshotVolumes.Value) } @@ -369,12 +238,6 @@ func (o *CreateOptions) applyOptionalBackupOptions(backupBuilder *builder.Backup if o.DefaultVolumesToFsBackup.Value != nil { backupBuilder.DefaultVolumesToFsBackup(*o.DefaultVolumesToFsBackup.Value) } - if o.ResPoliciesConfigmap != "" { - backupBuilder.ResourcePolicies(o.ResPoliciesConfigmap) - } - if o.ParallelFilesUpload > 0 { - backupBuilder.ParallelFilesUpload(o.ParallelFilesUpload) - } return nil } @@ -382,28 +245,12 @@ func (o *CreateOptions) applyOptionalBackupOptions(backupBuilder *builder.Backup // createNonAdminBackup creates the NonAdminBackup CR from a BackupSpec func (o *CreateOptions) createNonAdminBackup(namespace string, backupSpec *velerov1api.BackupSpec) *nacv1alpha1.NonAdminBackup { return ForNonAdminBackup(namespace, o.Name). - ObjectMeta( - WithLabelsMap(o.Labels.Data()), - WithAnnotationsMap(o.Annotations.Data()), - ). BackupSpec(nacv1alpha1.NonAdminBackupSpec{ BackupSpec: backupSpec, }). Result() } -func (o *CreateOptions) oldAndNewFilterParametersUsedTogether() bool { - haveOldResourceFilterParameters := len(o.IncludeResources) > 0 || - len(o.ExcludeResources) > 0 || - o.IncludeClusterResources.Value != nil - haveNewResourceFilterParameters := len(o.IncludeClusterScopedResources) > 0 || - (len(o.ExcludeClusterScopedResources) > 0) || - (len(o.IncludeNamespaceScopedResources) > 0) || - (len(o.ExcludeNamespaceScopedResources) > 0) - - return haveOldResourceFilterParameters && haveNewResourceFilterParameters -} - // promptForceConfirmation prompts the user to confirm when using --force without storage location func (o *CreateOptions) promptForceConfirmation() error { if !o.Force || o.StorageLocation != "" { @@ -413,12 +260,6 @@ func (o *CreateOptions) promptForceConfirmation() error { fmt.Println("\nWARNING: Using --force without specifying a storage location is not ideal.") fmt.Println("This will use admin defaults and certain features like logs may not work as expected.") - if o.AssumeYes { - fmt.Println("Proceeding with --assume-yes flag.") - fmt.Println() - return nil - } - fmt.Print("Do you want to continue? (y/N): ") reader := bufio.NewReader(os.Stdin) response, err := reader.ReadString('\n') 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/README.md b/cmd/non-admin/restore/README.md new file mode 100644 index 0000000..6990876 --- /dev/null +++ b/cmd/non-admin/restore/README.md @@ -0,0 +1,135 @@ +# NonAdminRestore Create Command + +## Overview + +The `nonadmin restore create` command creates restore requests for non-admin users within their authorized namespaces. + +## Minimal MVP Flags + +The following flags represent the minimal viable product for restore creation (7 total): + +### Core Flags + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--backup-name` | String | - | Source backup (required) | ✅ MVP | +| `--include-resources` | StringArray | `["*"]` | Resources to include | ✅ MVP | +| `--exclude-resources` | StringArray | - | Resources to exclude | ✅ MVP | + +### Label Selection + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--selector`, `-l` | LabelSelector | - | Label selector | ✅ MVP | +| `--or-selector` | OrLabelSelector | - | OR label selectors | ✅ MVP | + +### Cluster Resources + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--include-cluster-resources` | OptionalBool | - | Include cluster resources | ✅ MVP | + +### Timing + +| Flag | Type | Default | Description | Status | +|------|------|---------|-------------|--------| +| `--item-operation-timeout` | Duration | - | Operation timeout | ✅ MVP | + +## Restricted Flags (Not Available) + +The following flags are **restricted** for non-admin users per the NAR API restrictions: + +| Flag | Reason | Doc Reference | +|------|--------|---------------| +| `--from-schedule` | Not supported for non-admin | NAR API docs | +| `--include-namespaces` | Restricted - automatically set | NAR API docs | +| `--exclude-namespaces` | Restricted for non-admin users | NAR API docs | +| `--namespace-mappings` | Restricted for non-admin users | NAR API docs | + +## Flags Not in MVP (Future Enhancements) + +The following flags are **allowed by the API** but not included in the minimal MVP: + +### Metadata +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--labels` | ✅ Yes | Future | +| `--annotations` | ✅ Yes | Future | + +### Restore Behavior +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--restore-volumes` | ✅ Yes | Future | +| `--preserve-nodeports` | ✅ Yes | Future | +| `--existing-resource-policy` | ✅ Yes | Future | + +### Advanced Features +| Flag | Admin Enforceable | Could Add Later | +|------|------------------|-----------------| +| `--resource-modifier-configmap` | ✅ Yes | Future | +| `--status-include-resources` | ✅ Yes | Future | +| `--status-exclude-resources` | ✅ Yes | Future | +| `--write-sparse-files` | ✅ Yes | Future | +| `--parallel-files-download` | ✅ Yes | Future | + +### UX Flags +| Flag | Purpose | Could Add Later | +|------|---------|-----------------| +| `--wait` | Wait for restore completion | Future | + +## Examples + +```bash +# Create a simple restore from a backup +oadp nonadmin restore create my-restore --backup-name my-backup + +# Create restore with specific resources +oadp nonadmin restore create my-restore \ + --backup-name my-backup \ + --include-resources deployments,services + +# Create restore excluding certain resources +oadp nonadmin restore create my-restore \ + --backup-name my-backup \ + --exclude-resources secrets + +# Create restore with label selector +oadp nonadmin restore create my-restore \ + --backup-name my-backup \ + --selector app=myapp + +# View the YAML without creating it +oadp nonadmin restore create my-restore \ + --backup-name my-backup \ + -o yaml +``` + +## Architecture Notes + +The restore create command uses **struct embedding** from Velero's restore CreateOptions. This approach: +- Reduces code duplication +- Ensures compatibility with Velero updates +- Uses BindFlags() as the control gate to expose only MVP features to non-admin users +- Maintains forward compatibility for future enhancements + +## Implementation Details + +### Struct Embedding Pattern + +```go +type CreateOptions struct { + *velerorestore.CreateOptions // Embed Velero's CreateOptions + + // NAR-specific fields + Name string + client kbclient.WithWatch + currentNamespace string +} +``` + +### MVP Flag Control + +The `BindFlags()` method acts as a control gate, exposing only the MVP flags while the embedded struct contains all Velero options. This allows: +- Easy addition of new flags in the future (just bind them in BindFlags) +- Automatic compatibility with Velero struct updates +- Clear separation between what's exposed vs what's available diff --git a/cmd/non-admin/restore/create.go b/cmd/non-admin/restore/create.go new file mode 100644 index 0000000..c05a938 --- /dev/null +++ b/cmd/non-admin/restore/create.go @@ -0,0 +1,195 @@ +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" + + "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/builder" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd" + velerorestore "github.com/vmware-tanzu/velero/pkg/cmd/cli/restore" + "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 (auto-generated name). + kubectl oadp nonadmin restore create --backup-name backup1 + + # Create a non-admin restore with a specific name. + kubectl oadp nonadmin restore create restore1 --backup-name backup1 + + # Create a non-admin restore with specific resource types. + kubectl oadp nonadmin restore create restore2 --backup-name backup1 --include-resources deployments,services + + # Create a non-admin restore excluding certain resources. + kubectl oadp nonadmin restore create restore3 --backup-name backup1 --exclude-resources secrets + + # Create a non-admin restore with label selector. + kubectl oadp nonadmin restore create restore4 --backup-name backup1 --selector app=myapp + + # 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 { + *velerorestore.CreateOptions + + // NAR-specific fields + Name string // The NonAdminRestore resource name (maps to Velero's RestoreName) + client kbclient.WithWatch + currentNamespace string +} + +func NewCreateOptions() *CreateOptions { + return &CreateOptions{ + CreateOptions: velerorestore.NewCreateOptions(), + } +} + +func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { + + flags.StringVar(&o.BackupName, "backup-name", "", "The backup to restore from.") + + // Label selection + 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.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.") + + 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 + } + + // Must specify backup-name + 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 { + // Name is optional - if not provided, will use GenerateName in the builder + if len(args) > 0 { + o.Name = args[0] + } else { + o.Name = "" + } + + // 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 + } + + // Use the actual name (either provided or auto-generated by the API server) + actualName := nonAdminRestore.Name + fmt.Printf("NonAdminRestore request %q submitted successfully.\n", actualName) + fmt.Printf("Run `oc oadp nonadmin restore describe %s` or `oc oadp nonadmin restore logs %s` for more details.\n", actualName, actualName) + 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). + IncludedResources(o.IncludeResources...). + ExcludedResources(o.ExcludeResources...). + LabelSelector(o.Selector.LabelSelector). + OrLabelSelector(o.OrSelector.OrLabelSelectors). + ItemOperationTimeout(o.ItemOperationTimeout) + + // Apply optional include-cluster-resources flag + if o.IncludeClusterResources.Value != nil { + restoreBuilder.IncludeClusterResources(*o.IncludeClusterResources.Value) + } + + tempRestore := restoreBuilder.Result() + + // Wrap in NonAdminRestore + return ForNonAdminRestore(namespace, o.Name). + 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..6a5a67a --- /dev/null +++ b/cmd/non-admin/restore/nonadminrestore_builder.go @@ -0,0 +1,172 @@ +/* +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 { + objMeta := metav1.ObjectMeta{ + Namespace: ns, + } + + // If name is empty, use GenerateName for auto-generation + if name == "" { + objMeta.GenerateName = "restore-" + } else { + objMeta.Name = name + } + + return &NonAdminRestoreBuilder{ + object: &nacv1alpha1.NonAdminRestore{ + TypeMeta: metav1.TypeMeta{ + APIVersion: nacv1alpha1.GroupVersion.String(), + Kind: "NonAdminRestore", + }, + ObjectMeta: objMeta, + }, + } +} + +// 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..c25ba9c --- /dev/null +++ b/cmd/non-admin/restore/restore_test.go @@ -0,0 +1,521 @@ +/* +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", + "--selector", + "--or-selector", + }, + }, + { + 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) { + // Minimal MVP flags only (based on NAR restrictions for non-admin users) + expectedFlags := []string{ + "--backup-name", + "--include-resources", + "--exclude-resources", + "--selector", + "--or-selector", + "--include-cluster-resources", + "--item-operation-timeout", + } + + 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", + "--include-resources", + "--selector", + } + + 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 flag", func(t *testing.T) { + testutil.TestHelpCommand(t, binaryPath, + []string{"nonadmin", "restore", "create", "--help"}, + []string{"--backup-name"}) + }) +} + +// 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`, }) } diff --git a/cmd/root.go b/cmd/root.go index fb82fce..ac757f1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -387,6 +387,11 @@ func NewVeleroRootCommand(baseName string) *cobra.Command { baseFactory := clientcmd.NewFactory(baseName, config) f := &timeoutFactory{Factory: baseFactory} + // Bind factory flags to enable -n/--namespace flag for admin commands. + // This allows admin Velero and NABSL commands to accept namespace via CLI flag. + // Nonadmin commands continue using GetCurrentNamespace() for security isolation. + f.BindFlags(c.PersistentFlags()) + c.AddCommand( backup.NewCommand(f), schedule.NewCommand(f), @@ -414,7 +419,12 @@ func NewVeleroRootCommand(baseName string) *cobra.Command { c.AddCommand(nonadmin.NewNonAdminCommand(f)) // Apply velero->oadp replacement to all commands recursively + // Skip nonadmin commands since we have full control over their output for _, cmd := range c.Commands() { + // Don't wrap nonadmin commands - we control them and they already use correct terminology + if cmd.Use == "nonadmin" || cmd.Use == "nabsl-request" { + continue + } replaceVeleroWithOADP(cmd) } diff --git a/docs/oadp-self-service.md b/docs/oadp-self-service.md new file mode 100644 index 0000000..7b5a588 --- /dev/null +++ b/docs/oadp-self-service.md @@ -0,0 +1,639 @@ +# OADP Self Service Overview + +## Overview + +OADP Self Service enables non-administrator users to perform backup and restore operations in their authorized namespaces without requiring +cluster-wide administrator privileges. This feature provides secure self-service data protection capabilities while maintaining proper administrator +controls, restrictions and enforcements over the user's backup and restore operations. + +### Key Benefits + +- Allows users to perform namespace-scoped backup and restore operations +- Provides users with secure access to backup logs and status information +- Enables users to create dedicated backup storage locations with user owned buckets and credentials +- Maintains cluster administrator control over non-administrator operations through +restrictions and enforcements + + +## OADP Self Service Details + +OADP self-service introduces a significant change to backup and restore operations in OpenShift. Previously, only cluster administrators could perform these operations. +Now, regular OpenShift users can perform backup and restore operations within their authorized namespaces. +This is achieved through custom resources that securely manage these operations while maintaining proper access controls and visibility. +The self-service functionality is implemented in a way that ensures users can only operate within their assigned namespaces and permissions, +while cluster administrators maintain overall control through restrictions and enforcements. + +### Glossary of terms + +* **NAB** - NonAdminBackup. A custom resource that users directly create to request a velero backup of the namespace from which the NAB object is created. +* **NAR** - NonAdminRestore. A custom resource that users directly create to request a velero restore of the namespace from which the NAR object is created. +* **NAC** - NonAdminController. A controller that validates the NAB and NAR objects and creates the velero backup and restore and related objects. The NAC is essentially a proxy between non admin users and velero. +* **NABSL** - NonAdminBackupStorageLocation. A custom resource that users directly create to request a velero backup storage location. Users can use object storage that is specifically created for their project, deliniated from other users and projects. +* **NADR** - NonAdminDownloadRequest. A custom resource that users directly create to request a velero backup download. Users will be provided with a secured URL to download details regarding the backup or restore. + +### Cluster Administrator Setup + +Install and configure the OADP operator according to the documentation and your requirements. + +To enable OADP Self-Service the DPA spec must the spec.nonAdmin.enable field to true. + +``` + nonAdmin: + enable: true +``` + +Once the OADP DPA is reconciled the cluster administrator should see the non-admin-controller running in the openshift-adp namespace. The Openshift users without cluster admin rights will now be able to create NAB or NAR objects and related objects in their namespace to create a backup or restore. + +## OpenShift User Instructions + +Prior to OpenShift users taking advantage of OADP self-service feature the OpenShift cluster administrator must have completed the following prerequisite steps: + +* The OADP DPA has been configured to support self-service +* The cluster administrator has created the users + * account + * namespace + * namespace privileges, e.g. namespace admin. + * optionally the cluster administrator can create a NABSL for the user. + +### OpenShift self-service required permissions: + +Ensure users have appropriate permissions in its namespace. Users must have editor roles for the following objects in their namespace. + * nonadminbackups.oadp.openshift.io + * nonadminbackupstoragelocations.oadp.openshift.io + * nonadminrestores.oadp.openshift.io + * nonadmindownloadrequests.oadp.openshift.io + + For example + ```yaml + # config/rbac/nonadminbackup_editor_role.yaml + - apiGroups: + - oadp.openshift.io + resources: + - nonadminbackups + - nonadminrestores + - nonadminbackupstoragelocations + - nonadmindownloadrequests + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - oadp.openshift.io + resources: + - nonadminbackups/status + verbs: + - get + # config/rbac/nonadminrestore_editor_role.yaml + - apiGroups: + - oadp.openshift.io + resources: + - nonadminrestores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - oadp.openshift.io + resources: + - nonadminrestores/status + verbs: + - get + ``` + + **note** Users will not be able to edit the NABSLs that led to the creation of the BSL. They will only have the ability to create new NABSLs. The NAC controller will disallow such edit and inform user within the condition of the edited NABSL object. + +## Self-Service workflow + +Non Cluster Administrators can utilize OADP self-service by creating NonAdminBackup (NAB) and NonAdminRestore (NAR) objects in the namespace to be backed up or restored. A NonAdminBackup is an OpenShift custom resource that securely facilitates the creation, status and lifecycle of a Velero Backup custom resource. + +```mermaid +sequenceDiagram + participant User + participant NAB as NonAdminBackup + participant NAC as NonAdminController + participant VB as Velero Backup + + User->>NAB: Creates NonAdminBackup CR + NAB->>NAC: Detected by controller + NAC-->>NAB: Validates backup request + NAC->>VB: Creates Velero Backup CR + VB-->>NAB: Status updates + NAB-->>User: View backup status +``` + +![nab-backup-workflow](https://hackmd.io/_uploads/BJz4bEbKyx.jpg) + +For the most part one can think of a NonAdminBackup and a Velero Backup in very much the same way. Both objects specify a velero backup and how the backup should be executed. There are a few differences to keep in mind when creating a NonAdminBackup. + +1. The NonAdminBackup creates the Velero Backup CR instance in a secure way that limits the users access. +2. A user cannot specify the namespace that will be backed up. The namespace from which the NAB object is created is the defined namespace to be backed up. +3. In addition to the creation of the Velero Backup the NonAdminBackup object's main purpose is to track the status of the Velero Backup in a secure and clear way. + +### NonAdminBackup NAB Example: + +``` +apiVersion: oadp.openshift.io/v1alpha1 +kind: NonAdminBackup +metadata: + name: mybackup-1 + namespace: nacuser1 +spec: + backupSpec: + snapshotMoveData: true +``` + +Once created the NAB will look similar to the following: + +``` +apiVersion: oadp.openshift.io/v1alpha1 +kind: NonAdminBackup +metadata: + creationTimestamp: "2025-02-21T20:57:35Z" + finalizers: + - nonadminbackup.oadp.openshift.io/finalizer + generation: 2 + name: mybackup-1 + namespace: nacuser1 <--- The namespace that NAC controller will set on the Velero Backup object to backup + resourceVersion: "20714121" + uid: 93effb39-9762-4d04-8e9e-194ebe6b9b31 +spec: + backupSpec: + snapshotMoveData: true +status: + conditions: + - lastTransitionTime: "2025-02-21T20:57:35Z" + message: backup accepted <--- The NAC controller reconciled the NAB object and created the Velero Backup object + reason: BackupAccepted + status: "True" + type: Accepted + - lastTransitionTime: "2025-02-21T20:57:35Z" + message: Created Velero Backup object + reason: BackupScheduled + status: "True" + type: Queued + phase: Created <--- The NAB object is in the Created phase + queueInfo: + estimatedQueuePosition: 0 <--- The NAB object status in the queue, once complete it is set to 0 + veleroBackup: + nacuuid: nacuser1-mybackup-1-588ce989-387e-4352-b04a-1fd6b7712370 <--- The NAC controller created the Velero Backup object and set the nacuuid + name: nacuser1-mybackup-1-588ce989-387e-4352-b04a-1fd6b7712370 <--- The associated Velero Backup name + namespace: openshift-adp + status: <--- The status of the Velero backup object displayed by the NAB object + backupItemOperationsAttempted: 3 + backupItemOperationsCompleted: 3 + completionTimestamp: "2025-02-21T20:59:28Z" + expiration: "2025-03-23T20:57:35Z" + formatVersion: 1.1.0 + hookStatus: {} + phase: Completed <--- The Velero backup object is in the Completed phase, successful + progress: + itemsBackedUp: 57 + totalItems: 57 + startTimestamp: "2025-02-21T20:57:35Z" + version: 1 +``` +The complete nonAdminBackup resource definition can be found here: [NonAdminBackup CRD](https://github.com/openshift/oadp-operator/blob/master/bundle/manifests/oadp.openshift.io_nonadminbackups.yaml) + +### NonAdminRestore NAR Example: + +``` +apiVersion: oadp.openshift.io/v1alpha1 +kind: NonAdminRestore +metadata: + name: example + namespace: nacuser1 +spec: + restoreSpec: + backupName: mybackup-1 <-- references the NAB object, not the Velero backup object +``` + +Once created the NAR will look similar to the following: + +``` +apiVersion: oadp.openshift.io/v1alpha1 +kind: NonAdminRestore +metadata: + creationTimestamp: "2025-02-21T21:12:54Z" + finalizers: + - nonadminrestore.oadp.openshift.io/finalizer + generation: 2 + name: example + namespace: nacuser1 + resourceVersion: "20719136" + uid: 0f1d8346-d8be-4621-8d67-0877f15e82fb +spec: + restoreSpec: + backupName: mybackup-1 + hooks: {} + itemOperationTimeout: 0s +status: + conditions: + - lastTransitionTime: "2025-02-21T21:12:54Z" + message: restore accepted <--- The NAC controller reconciled the NAR object and created the Velero Restore object + reason: RestoreAccepted + status: "True" + type: Accepted + - lastTransitionTime: "2025-02-21T21:12:54Z" + message: Created Velero Restore object <--- The NAC controller created the Velero Restore object + reason: RestoreScheduled + status: "True" + type: Queued + phase: Created <--- The NAR object is in the Created phase + queueInfo: + estimatedQueuePosition: 0 <--- The NAR object status in the queue, once complete it is set to 0 + veleroRestore: + nacuuid: nacuser1-example-b844be97-7ee4-4702-91b8-ffc84697675a <--- The NAC controller created the Velero Restore object and set the nacuuid + name: nacuser1-example-b844be97-7ee4-4702-91b8-ffc84697675a <--- The associated Velero Restore name + namespace: openshift-adp + status: + completionTimestamp: "2025-02-21T21:12:57Z" + hookStatus: {} + phase: Completed <--- The Velero restore object is in the Completed phase, successful + progress: + itemsRestored: 54 + totalItems: 54 + startTimestamp: "2025-02-21T21:12:55Z" + warnings: 0 +``` +The complete nonAdminRestore resource definition can be found here: [NonAdminRestore CRD](https://github.com/openshift/oadp-operator/blob/master/bundle/manifests/oadp.openshift.io_nonadminrestores.yaml) + +### NonAdminBackupStorageLocation NABSL: +Cluster administrators can gain efficiencies by delegating backup and restore operations to OpenShift users. It is recommended that cluster administrators carefully manage the NABSL to conform to any company policies, compliance requirements, etc. + +1. **Direct Creation**: Cluster administrators can create NABSLs directly for non-admin users. +2. **Approval Workflow**: Cluster administrators can enable an approval process where: + - Users create a NABSL, which triggers the creation of a NonAdminBackupStorageLocationRequest object in the openshift-adp namespace. + - Administrators review and either approve or reject these requests. Once approved, a Velero BSL is created in the openshift-adp namespace, and the user is notified of the approval on the NABSL status. If rejected, the status of the NABSL is updated to reflect the rejection. + - Administrators can also revoke previously approved NABSL, which results in the removal of the Velero BSL, and the user is notified of the rejection. This is achieved by modifying the approve field back to pending or reject. + - This is an opt-in feature and must be explicitly enabled by the cluster administrator. +3. **Automatic Approval**: Users create NABSL from the user namespace, these are automatically approved when nonAdmin.requireApprovalForBSL is set to false or not set. + +For security purposes it is recommended that cluster administrators use either the direct creation or the approval workflow. The automatic approval option is less secure as it does not require administrator review. It should also be noted that updating the NABSL after the initial creation will NOT change the associated Velero BSL. Updating the NABSL is not supported for non-admin users. + + +To enable the approval workflow the DPA spec must be set as follows: +``` + nonAdmin: + enable: true + requireApprovalForBSL: true +``` +Cluster administrators can view the NABSLApprovalRequest object in the openshift-adp namespace. + +``` +oc -n openshift-adp get NonAdminBackupStorageLocationRequests +``` +Approval or rejection is accomplished by updating the NABSLApprovalRequest object. + +``` +spec: + approvalDecision: reject [accept, reject] +``` + +If approved both the NABSL and the BSL are created. The NABSL is created in the users namespace, while the BSL is created in the OADP namespace, such as openshift-adp. + +### User Creation of NABSL: + +A non-admin user can create a NABSL in their namespace. + +``` +apiVersion: oadp.openshift.io/v1alpha1 +kind: NonAdminBackupStorageLocation +metadata: + name: nacuser1-nabsl + namespace: nacuser1 +spec: + backupStorageLocationSpec: + config: + checksumAlgorithm: "" + profile: default + region: us-west-2 + credential: + key: cloud + name: cloud-credentials + objectStorage: + bucket: bucket1uswest2 + prefix: velero + provider: aws +``` + +If the cluster administrator has enabled the requireApprovalForBSL flag then the NABSL will be in the Pending state until the cluster administrator approves the NABSL. + + + +### NAB / NAR Status + +#### Phase +The phase field is a simple one high-level summary of the lifecycle of the objects, that only moves forward. Once a phase changes, it can not return to the previous value. + +| **Value** | **Description** | +|-----------|-----------------| +| New | *NonAdminBackup/NonAdminRestore* resource was accepted by the NAB/NAR Controller, but it has not yet been validated by the NAB/NAR Controller | +| BackingOff | *NonAdminBackup/NonAdminRestore* resource was invalidated by the NAB/NAR Controller, due to invalid Spec. NAB/NAR Controller will not reconcile the object further, until user updates it | +| Created | *NonAdminBackup/NonAdminRestore* resource was validated by the NAB/NAR Controller and Velero *Backup/restore* was created. The Phase will not have additional information about the *Backup/Restore* run | +| Deletion | *NonAdminBackup/NonAdminRestore* resource has been marked for deletion. The NAB/NAR Controller will delete the corresponding Velero *Backup/Restore* if it exists. Once this deletion completes, the *NonAdminBackup/NonAdminRestore* object itself will also be removed | + + + + + +## Advanced Cluster Administrator Features + +### Cluster Administrator Enforceable Spec Fields +There are several types of cluster scoped objects that non-admin users should not have access to backup or restore. OADP self-service automatically excludes the following list of cluster scoped resources from being backed up or restored. + +* SCCs +* ClusterRoles +* ClusterRoleBindings +* CRDs +* PriorityClasses +* virtualmachineclusterinstancetypes +* virtualmachineclusterpreferences + +Cluster administrators may also enforce company or compliance policy by utilizing templated NABSL's, NAB's and NAR's that require fields values to be set and conform to the administrator defined policy. Admin Enforceable fields are fields that the cluster administrator can enforce non cluster admin users to use. Restricted fields are automatically managed by OADP and cannot be modified by either administrators or users. + +#### NABSL +The following NABSL fields are currently supported for template enforcement: + +| **NABSL Field** | **Admin Enforceable** | **Restricted** | **special case** | +|----------------------------|-----------------|----------------|-----------------| +| `backupSyncPeriod` | | | ⚠️ Must be set lower than the DPA.backupSyncPeriod and lower than the garbage collection period | +| `provider` | | | ⚠️ special case | +| `objectStorage` | ✅ Yes | | | +| `credential` | ✅ Yes | | | +| `config` | ✅ Yes | | | +| `accessMode` | ✅ Yes | | | +| `validationFrequency` | ✅ Yes | | | +| `default` | | | ⚠️ Must be false or empty | + +For example if the cluster administrator wanted to mandate that all NABSL's used a particular aws s3 bucket. + +``` +spec: + config: + checksumAlgorithm: "" + profile: default + region: us-west-2 + credential: + key: cloud + name: cloud-credentials + objectStorage: + bucket: my-company-bucket <--- + prefix: velero + provider: aws +``` +The DPA spec must be set in the following way: + +``` +nonAdmin: + enable: true + enforceBSLSpec: + config: <--- entire config must match expected NaBSL config + checksumAlgorithm: "" + profile: default + region: us-west-2 + objectStorage: <--- all of the objectStorage options must match expected NaBSL options + bucket: my-company-bucket + prefix: velero + provider: aws +``` + +#### Restricted NonAdminBackups + +In the same sense as the NABSL, cluster administrators can also restrict the NonAdminBackup spec fields to ensure the backup request conforms to the administrator defined policy. Most of the backup spec fields can be restricted by the cluster administrator, below is a table of reference for the current implementation. + + +| **Backup Spec Field** | **Admin Enforceable** | **Restricted** | **special case** | +|--------------------------------------------|--------------|--------------------------|-----------------| +| `csiSnapshotTimeout` | ✅ Yes | | | +| `itemOperationTimeout` | ✅ Yes | | | +| `resourcePolicy` | ✅ Yes | | ⚠️ Non-admin users can specify the config-map that admins created in OADP Operator NS(Admins enforcing this value be a good alternative here), they cannot specify their own configmap as its lifecycle handling is not currently managed by NAC controller | +| `includedNamespaces` | ❌ No | ✅ Yes | ⚠️ Admins cannot enforce this because it does not make sense for a cluster wide non-admin backup setting, we have validations in place such that only the NS admins NS in included in the NAB spec. | +| `excludedNamespaces` | ✅ Yes | ✅ Yes | ⚠️ This spec is restricted for non-admin users and hence not enforceable by admins | +| `includedResources` | ✅ Yes | | | +| `excludedResources` | ✅ Yes | | | +| `orderedResources` | ✅ Yes | | | +| `includeClusterResources` | ✅ Yes | | ⚠️ Non-admin users can only set this spec to false if they want, all other values are restricted, similar rule for admin enforcement regarding this spec value. | +| `excludedClusterScopedResources` | ✅ Yes | | | +| `includedClusterScopedResources` | ✅ Yes | | ⚠️ This spec is restricted and non-enforceable, only empty list is acceptable | +| `excludedNamespaceScopedResources` | ✅ Yes | | | +| `includedNamespaceScopedResources` | ✅ Yes | | | +| `labelSelector` | ✅ Yes | | | +| `orLabelSelectors` | ✅ Yes | | | +| `snapshotVolumes` | ✅ Yes | | | +| `storageLocation` | | | ⚠️ Can be empty (implying default BSL usage) or needs to be an existing NABSL | +| `volumeSnapshotLocations` | | | ⚠️ Not supported for non-admin users, default will be used if needed | +| `ttl` | ✅ Yes | | | +| `defaultVolumesToFsBackup` | ✅ Yes | | | +| `snapshotMoveData` | ✅ Yes | | | +| `datamover` | ✅ Yes | | | +| `uploaderConfig.parallelFilesUpload` | ✅ Yes | | | +| `hooks` | | | ⚠️ special case | + +An example enforcement set in the DPA spec to enforce the + * ttl to be set to "158h0m0s" + * snapshotMoveData to be set to true + +``` + nonAdmin: + enable: true + enforcedBackupSpec.ttl: "158h0m0s" + enforcedBackupSpec.snapshotMoveData: true +``` + +#### Restricted NonAdminRestore NAR + +NonAdminRestores spec fields can also be restricted by the cluster administrator. The following NAR spec fields are currently supported for template enforcement: + +| **Field** | **Admin Enforceable** | **Restricted** | **special case** | +|-------------------------------|--------------|--------------------|-----------------| +| `backupName` | ❌ No | | | +| `scheduleName` | ❌ No | ✅ Yes | ⚠️ not supported for non-admin users, we don't have non-admin backup schedule API as of now. | +| `itemOperationTimeout` | ✅ Yes | | | +| `uploaderConfig` | ✅ Yes | | | +| `includedNamespaces` | ❌ No | ✅ Yes | ⚠️ restricted for non-admin users and hence non-enforceable by admins | +| `excludedNamespaces` | ❌ No | ✅ Yes | ⚠️ restricted for non-admin users and hence non-enforceable by admins | +| `includedResources` | ✅ Yes | | | +| `excludedResources` | ✅ Yes | | | +| `restoreStatus` | ✅ Yes | | | +| `includeClusterResources` | ✅ Yes | | | +| `labelSelector` | ✅ Yes | | | +| `orLabelSelectors` | ✅ Yes | | | +| `namespaceMapping` | ❌ No | ✅ Yes | ⚠️ restricted for non-admin users and hence non-enforceable by admins | +| `restorePVs` | ✅ Yes | | | +| `preserveNodePorts` | ✅ Yes | | | +| `existingResourcePolicy` | | | ⚠️ special case | +| `hooks` | | | ⚠️ special case | +| `resourceModifers` | | | ⚠️ Non-admin users can specify the config-map that admins created in OADP Operator NS(Admins enforcing this value be a good alternative here), they cannot specify their own configmap as its lifecycle handling is not currently managed by NAC controller | + + + +## OpenShift Console and OADP Self-Service + +At the time of writing OADP self-service objects can only be created in the OpenShift Console via the API Explorer. + +Navigate to: Administrator -> Home -> API Explorer -> Filter on `NonAdmin`. Choose the object you wish to create. + + * NonAdminBackup + * NonAdminRestore + * NonAdminBackupStorageLocation + * NonAdminDownloadRequest + +Click on instances and the create button. + + +## Unsupported features of OADP regarding self-service + * Cross Cluster or Migrations are NOT supported by self-service. This type of OADP operation is only supported for the cluster administrator. + * non-admin VSL's are not supported. The VSL created by the cluster-admin in DPA would be the only VSL non-admin users can employ. + * ResourceModifiers and Volume policies are not supported for non-admin user backup and restore operations. + * Backup and restore logs via NonAdminDownloadRequest is not supported for default BSL's. If the cluster administrator would + like users to have access to logs, NonAdminBackupStorageLocation's must be created for the non-admin users. + + +## Security Considerations for Cluster Administrators + + * By enabling self-service, cluster administrators will expose the name of the namespace where OADP is running via the backup logs. non-admin users are NOT granted any access to the OADP operator namespace. + * The nonadmin controller will not allow users to set includeClusterResources in a backup or restore. This is to prevent a scenario where a non-admin user would attempt to restore a cluster scoped resource to a namespace. OADP's backup policy is to automatically include cluster scoped resources like PV's that are associated with the namespace being backed up. Additionally cluster administrators can template an enforcement to `excludedClusterScopedResources` to prevent cluster scoped resources from being backed up. + +--- + +# CLI Implementation: MVP Approach + +## Overview + +The OADP CLI implements a Minimal Viable Product (MVP) approach for the `nonadmin backup create` and `nonadmin restore create` commands. This approach uses **struct embedding** from Velero's CreateOptions to reduce code duplication while maintaining clear control over which features are exposed to non-admin users. + +## Implementation Pattern: Struct Embedding + +Both commands follow the same pattern established by Velero's CLI: + +```go +type CreateOptions struct { + *velero.CreateOptions // Embed Velero's full feature set + + // NAB/NAR-specific fields + Name string + Force bool // backup only + client kbclient.WithWatch + currentNamespace string +} +``` + +### Benefits + +- ✅ **Reduces Code Duplication** - No need to manually declare 30+ fields +- ✅ **Automatic Velero Compatibility** - Struct updates flow automatically +- ✅ **Clear Control Gate** - `BindFlags()` controls what's exposed to users +- ✅ **Easy Enhancement Path** - Add flags incrementally as features mature +- ✅ **Type Safety** - Reuses Velero's validated type definitions + +## Backup Create: MVP Flags (13 total) + +### Resource Filtering (2) +- `--include-resources` (default: `["*"]`) +- `--exclude-resources` + +### Label Selection (2) +- `--selector` / `-l` +- `--or-selector` + +### Cluster Resources (1) +- `--include-cluster-resources` (users can only set to false) + +### Timing & Storage (4) +- `--ttl` +- `--storage-location` +- `--csi-snapshot-timeout` +- `--item-operation-timeout` + +### Snapshot Control (3) +- `--snapshot-volumes` +- `--snapshot-move-data` +- `--default-volumes-to-fs-backup` + +### Control Flags (1) +- `--force` (bypass storage-location requirement) + +### Flags Removed from Original Implementation + +**Restricted (API limitations):** +- `--include-namespaces` - Auto-managed by NAC +- `--exclude-namespaces` - Restricted for non-admin +- `--include-cluster-scoped-resources` - Only empty list acceptable +- `--volume-snapshot-locations` - Not supported + +**Not in MVP (future enhancements):** +- `--labels` / `--annotations` - Metadata +- `--from-schedule` - Requires schedule API +- `--ordered-resources` - Advanced feature +- `--data-mover` - Advanced feature +- `--resource-policies-configmap` - Advanced feature +- `--parallel-files-upload` - Performance tuning +- `--exclude-cluster-scoped-resources` - Advanced filtering +- `--include-namespace-scoped-resources` - Advanced filtering +- `--exclude-namespace-scoped-resources` - Advanced filtering + +## Restore Create: MVP Flags (7 total) + +### Core (3) +- `--backup-name` (required) +- `--include-resources` +- `--exclude-resources` + +### Label Selection (2) +- `--selector` / `-l` +- `--or-selector` + +### Cluster Resources (1) +- `--include-cluster-resources` + +### Timing (1) +- `--item-operation-timeout` + +## Documentation + +Comprehensive README files document the MVP approach: + +- **cmd/non-admin/backup/README.md** - Complete backup flag reference +- **cmd/non-admin/restore/README.md** - Complete restore flag reference + +Each README includes: +- MVP flags table with descriptions +- Restricted flags table with reasons +- Future enhancement flags table +- Examples +- Architecture notes + +## Migration from Previous Version + +Users previously using non-MVP flags will need to: + +1. Remove unsupported flags from backup/restore commands +2. For metadata (`--labels`/`--annotations`): Use kubectl to add after creation +3. For `--from-schedule`: Create from admin schedule spec directly + +## Alignment with NAB/NAR API + +The MVP flags directly correspond to the API restrictions tables shown above: + +| CLI Approach | API Field Status | +|--------------|------------------| +| Not exposed | Restricted | +| MVP flag | Admin enforceable or unrestricted | +| Future enhancement | Admin enforceable | + +This ensures the CLI accurately reflects the underlying API capabilities and restrictions. + + + + + + +