diff --git a/README.md b/README.md index 7f815f31..d0712418 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,35 @@ [![Cross-Architecture Build Test](https://github.com/migtools/oadp-cli/actions/workflows/cross-arch-build-test.yml/badge.svg)](https://github.com/migtools/oadp-cli/actions/workflows/cross-arch-build-test.yml) -A kubectl plugin for working with OpenShift API for Data Protection (OADP) resources, including NonAdminBackup operations. +A kubectl plugin for OpenShift API for Data Protection (OADP) that provides both administrative and non-administrative backup operations. -> This project provides a `kubectl` plugin CLI that extends OADP functionality, allowing users to work with both regular Velero resources and NonAdminBackup resources through a unified interface. +> **What it does**: Extends OADP functionality with a unified CLI that supports both cluster-wide Velero operations (admin) and namespace-scoped self-service operations (non-admin users). -## Features +## Key Capabilities -- **Regular OADP operations**: Standard Velero backup, restore, and version commands -- **NonAdmin operations**: Create and manage NonAdminBackup resources for namespace-scoped backup operations -- **Automatic namespace detection**: NonAdminBackup automatically uses your current kubectl context namespace -- **Kubectl plugin integration**: Works seamlessly as a kubectl plugin +- **Admin Operations**: Full Velero backup, restore, and version commands (requires cluster admin permissions) +- **Non-Admin Operations**: Namespace-scoped backup operations using non-admin CRDs (works with regular user permissions) +- **Smart Namespace Handling**: Non-admin commands automatically operate in your current kubectl context namespace +- **Seamless Integration**: Works as a standard kubectl plugin ## Command Structure ``` -oadp -├── backup (Velero backups) -├── restore (Velero restores) -├── version -└── nonadmin +kubectl oadp +├── backup # Velero cluster-wide backups (admin) +├── restore # Velero cluster-wide restores (admin) +├── version # Version information +└── nonadmin (na) # Namespace-scoped operations (non-admin) └── backup - └── create + ├── create + ├── describe + ├── logs + └── delete ``` ## Installation -### Using Krew (Recommended) - -[Krew](https://krew.sigs.k8s.io/) is the recommended way to install kubectl plugins. +### Using Krew (Available soon!) ```sh # Install Krew if you haven't already @@ -42,142 +43,136 @@ kubectl krew install oadp kubectl oadp --help ``` -**Note:** The LICENSE file is automatically extracted during Krew installation and available in the plugin directory. - -## Build and Install - -### Quick Installation - -Use the Makefile for easy build and installation: +### Manual Build and Install ```sh -# Build and install the kubectl plugin +# Build and install directly make install + +# Or build manually +make build +sudo mv kubectl-oadp /usr/local/bin/ ``` -### Manual Installation +## Usage Guide -1. **Build the CLI:** - ```sh - make build - ``` +### Non-Admin Backup Operations -2. **Install as kubectl plugin:** - ```sh - sudo mv kubectl-oadp /usr/local/bin/ - ``` +Non-admin commands work within your current namespace and user permissions: -3. **Verify installation:** - ```sh - kubectl oadp --help - ``` +```sh +# Basic backup of current namespace +kubectl oadp nonadmin backup create my-backup +# Short form +kubectl oadp na backup create my-backup -### Development Workflow +# Include specific resource types only +kubectl oadp na backup create app-backup --include-resources deployments,services,configmaps -```sh -# Build and test locally -make build -./kubectl-oadp --help +# Exclude sensitive data +kubectl oadp na backup create safe-backup --exclude-resources secrets -# Run tests -make test +# Preview backup configuration without creating +kubectl oadp na backup create test-backup --snapshot-volumes=false -o yaml -# Check status -make status +# Create backup and wait for completion +kubectl oadp na backup create prod-backup --wait -# View all available commands -make help +# Check backup status +kubectl oadp na backup describe my-backup + +# View backup logs +kubectl oadp na backup logs my-backup + +# Delete a backup +kubectl oadp na backup delete my-backup ``` -### Release Process +### Admin Operations -For maintainers creating releases: +Admin commands require cluster-level permissions and operate across all namespaces: ```sh -# Build release archives for all platforms (includes LICENSE file) -make release +# Cluster-wide backup operations +kubectl oadp backup create cluster-backup --include-namespaces namespace1,namespace2 -# Generate Krew plugin manifest with SHA256 checksums -make krew-manifest +# Restore operations +kubectl oadp restore create --from-backup cluster-backup -# Clean up build artifacts -make clean +# Check OADP/Velero version +kubectl oadp version ``` -The release process creates: -- Platform-specific archives (`kubectl-oadp-OS-ARCH.tar.gz`) containing the binary and LICENSE file -- SHA256 checksums for each archive -- A Krew plugin manifest file with proper checksums for distribution - -## Usage Examples +## How Non-Admin Backups Work -### NonAdminBackup Operations +1. **Namespace Detection**: Commands automatically use your current kubectl context namespace +2. **Permission Model**: Works with standard namespace-level RBAC permissions +3. **Resource Creation**: Creates `Non-admin` custom resources that are processed by the OADP operator +4. **Velero Integration**: OADP operator translates NonAdminBackup resources into standard Velero backup jobs +**Example workflow:** ```sh -# Create a non-admin backup of the current namespace -kubectl oadp nonadmin backup create my-backup - -# Create backup with specific resource types -kubectl oadp nonadmin backup create my-backup --include-resources deployments,services - -# Create backup excluding certain resources -kubectl oadp nonadmin backup create my-backup --exclude-resources secrets +# Switch to your project namespace +kubectl config set-context --current --namespace=my-project -# View backup YAML without creating it -kubectl oadp nonadmin backup create my-backup --snapshot-volumes=false -o yaml +# Create backup (automatically backs up 'my-project' namespace) +kubectl oadp na backup create project-backup --wait -# Wait for backup completion -kubectl oadp nonadmin backup create my-backup --wait +# Monitor progress +kubectl oadp na backup logs project-backup ``` -### Regular OADP Operations +## Development + +### Quick Development Commands ```sh -# Work with regular Velero backups -kubectl oadp backup --help +# Build and test locally +make build +./kubectl-oadp --help -# Work with restores -kubectl oadp restore --help +# Run integration tests +make test -# Check version -kubectl oadp version +# Build release archives +make release + +# Generate Krew manifest +make krew-manifest ``` -## Key Features of NonAdminBackup +### Project Structure -- **Namespace-scoped**: Automatically backs up the namespace where the NonAdminBackup resource is created -- **Simplified workflow**: No need to specify `--include-namespaces` - it uses your current kubectl context -- **Permission-aware**: Works within the permissions of the current user/service account -- **Integration with OADP**: Leverages the underlying Velero infrastructure managed by OADP operator +- **`cmd/`**: Command definitions and CLI logic +- **`cmd/non-admin/`**: Non-admin specific commands +- **`tests/`**: Integration tests and test utilities +- **`Makefile`**: Build automation and common tasks ## Testing -This project includes comprehensive CLI integration tests organized by functionality. - -### Quick Test Commands +Comprehensive integration tests verify CLI functionality: ```bash # Run all tests make test -# Standard Go pattern (also works) -go test ./... +# For detailed test information +cat tests/README.md ``` -📖 **For detailed test documentation, see [tests/README.md](tests/README.md)** +## Technical Details -## Development +**Built with:** +- [Cobra](https://github.com/spf13/cobra) - CLI framework +- [Velero client libraries](https://github.com/vmware-tanzu/velero) - Core backup functionality +- [OADP NonAdmin APIs](https://github.com/migtools/oadp-non-admin) - NonAdminBackup CRD support -This CLI is built using: -- [Cobra](https://github.com/spf13/cobra) for CLI framework -- [Velero client libraries](https://github.com/vmware-tanzu/velero) for core functionality -- [OADP NonAdmin APIs](https://github.com/migtools/oadp-non-admin) for NonAdminBackup operations +**Dependencies:** +- OADP Operator installed in cluster +- Appropriate RBAC permissions for your use case ## License -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. +Apache License 2.0 - see [LICENSE](LICENSE) file. -This CLI builds on and integrates with: -- [Velero](https://github.com/vmware-tanzu/velero) (Apache 2.0) -- [OADP](https://github.com/openshift/oadp-operator) (Apache 2.0) -- [Kubernetes](https://github.com/kubernetes/kubernetes) (Apache 2.0) \ No newline at end of file +Integrates with Apache 2.0 licensed projects: [Velero](https://github.com/vmware-tanzu/velero), [OADP](https://github.com/openshift/oadp-operator), [Kubernetes](https://github.com/kubernetes/kubernetes). \ No newline at end of file diff --git a/cmd/non-admin/backup/backup.go b/cmd/non-admin/backup/backup.go index 38f00ca9..938004cd 100644 --- a/cmd/non-admin/backup/backup.go +++ b/cmd/non-admin/backup/backup.go @@ -32,6 +32,7 @@ func NewBackupCommand(f client.Factory) *cobra.Command { c.AddCommand( NewCreateCommand(f, "create"), + NewGetCommand(f, "get"), NewLogsCommand(f, "logs"), NewDescribeCommand(f, "describe"), NewDeleteCommand(f, "delete"), diff --git a/cmd/non-admin/backup/create.go b/cmd/non-admin/backup/create.go index dcd315cb..fac20670 100644 --- a/cmd/non-admin/backup/create.go +++ b/cmd/non-admin/backup/create.go @@ -17,8 +17,10 @@ limitations under the License. */ import ( + "bufio" "context" "fmt" + "os" "strings" "time" @@ -27,6 +29,7 @@ import ( "k8s.io/client-go/tools/cache" kbclient "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/migtools/oadp-cli/cmd/shared" nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/builder" @@ -50,19 +53,25 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command { 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 + kubectl oadp nonadmin backup create backup1 --storage-location my-nabsl # Create a non-admin backup with specific resource types. - kubectl oadp nonadmin backup create backup2 --include-resources deployments,services + kubectl oadp nonadmin backup create backup2 --include-resources deployments,services --storage-location my-nabsl # Create a non-admin backup excluding certain resources. - kubectl oadp nonadmin backup create backup3 --exclude-resources secrets + kubectl oadp nonadmin backup create backup3 --exclude-resources secrets --storage-location my-nabsl + + # Force creation with admin defaults (no storage location specified). + kubectl oadp nonadmin backup create backup4 --force + + # Force creation with admin defaults non-interactively. + kubectl oadp nonadmin backup create backup5 --force --assume-yes # View the YAML for a non-admin backup that doesn't snapshot volumes, without sending it to the server. - kubectl oadp nonadmin backup create backup4 --snapshot-volumes=false -o yaml + kubectl oadp nonadmin backup create backup6 --snapshot-volumes=false --storage-location my-nabsl -o yaml # Wait for a non-admin backup to complete before returning from the command. - kubectl oadp nonadmin backup create backup5 --wait`, + kubectl oadp nonadmin backup create backup7 --wait --storage-location my-nabsl`, } o.BindFlags(c.Flags()) @@ -100,6 +109,8 @@ type CreateOptions struct { CSISnapshotTimeout time.Duration ItemOperationTimeout time.Duration ResPoliciesConfigmap string + Force bool + AssumeYes bool client kbclient.WithWatch ParallelFilesUpload int currentNamespace string @@ -149,6 +160,8 @@ func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { 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") + 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.") } // BindWait binds the wait flag separately so it is not called by other create @@ -191,6 +204,10 @@ func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Facto // Note: Storage location and snapshot location validation removed for NonAdminBackup // as these are typically managed by the underlying Velero backup resource + 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") + } + return nil } @@ -210,19 +227,17 @@ func (o *CreateOptions) Complete(args []string, f client.Factory) error { if len(args) > 0 { o.Name = args[0] } - client, err := f.KubebuilderWatchClient() - if err != nil { - return err - } - // Add NonAdminBackup types to the scheme - err = nacv1alpha1.AddToScheme(client.Scheme()) + // Create client with NonAdmin scheme + client, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + }) if err != nil { - return fmt.Errorf("failed to add NonAdminBackup types to scheme: %w", err) + return err } // Get the current namespace from kubeconfig instead of using factory namespace - currentNS, err := getCurrentNamespace() + currentNS, err := shared.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to determine current namespace: %w", err) } @@ -246,6 +261,31 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { fmt.Println("Creating non-admin backup from schedule, all other filters are ignored.") } + // Warning prompt when using force flag without storage location + if o.Force && o.StorageLocation == "" { + fmt.Println("\nWARNING: Using --force without specifying a storage location is not ideal.") + fmt.Println("This will use admin defaults and certain features like logs may not work as expected.") + + if !o.AssumeYes { + fmt.Print("Do you want to continue? (y/N): ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Operation cancelled.") + return nil + } + } else { + fmt.Println("Proceeding with --assume-yes flag.") + } + fmt.Println() // Add blank line for better formatting + } + var updates chan *nacv1alpha1.NonAdminBackup if o.Wait { stop := make(chan struct{}) @@ -296,7 +336,11 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { return err } - fmt.Printf("NonAdminBackup request %q submitted successfully.\n", nonAdminBackup.Name) + if o.Force && o.StorageLocation == "" { + fmt.Printf("NonAdminBackup request %q submitted successfully (using admin defaults).\n", nonAdminBackup.Name) + } else { + fmt.Printf("NonAdminBackup request %q submitted successfully.\n", nonAdminBackup.Name) + } if o.Wait { fmt.Println("Waiting for non-admin backup to complete. You may safely press ctrl-c to stop waiting - your backup will continue in the background.") ticker := time.NewTicker(time.Second) @@ -314,7 +358,11 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { // Check NonAdminBackup status phase for completion states if backup.Status.Phase == "BackupDone" || backup.Status.Phase == "BackupFailed" { - fmt.Printf("\nNonAdminBackup completed with status: %s. You may check for more information using the commands `oadp nonadmin backup describe %s` and `oadp nonadmin backup logs %s`.\n", backup.Status.Phase, backup.Name, backup.Name) + if o.Force && o.StorageLocation == "" { + fmt.Printf("\nNonAdminBackup completed with status: %s (using admin defaults). You may check for more information using the commands `oadp nonadmin backup describe %s` and `oadp nonadmin backup logs %s`.\n", backup.Status.Phase, backup.Name, backup.Name) + } else { + fmt.Printf("\nNonAdminBackup completed with status: %s. You may check for more information using the commands `oadp nonadmin backup describe %s` and `oadp nonadmin backup logs %s`.\n", backup.Status.Phase, backup.Name, backup.Name) + } return nil } } @@ -322,7 +370,11 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { } // Not waiting - fmt.Printf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details.\n", nonAdminBackup.Name, nonAdminBackup.Name) + if o.Force && o.StorageLocation == "" { + fmt.Printf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details. (Created using admin defaults)\n", nonAdminBackup.Name, nonAdminBackup.Name) + } else { + fmt.Printf("Run `oc oadp nonadmin backup describe %s` or `oc oadp nonadmin backup logs %s` for more details.\n", nonAdminBackup.Name, nonAdminBackup.Name) + } return nil } diff --git a/cmd/non-admin/backup/delete.go b/cmd/non-admin/backup/delete.go index c4f22e8b..64385147 100644 --- a/cmd/non-admin/backup/delete.go +++ b/cmd/non-admin/backup/delete.go @@ -32,6 +32,7 @@ import ( "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" ) @@ -80,22 +81,18 @@ func (o *DeleteOptions) BindFlags(flags *pflag.FlagSet) { func (o *DeleteOptions) Complete(args []string, f client.Factory) error { o.Names = args - // Get the Kubernetes client - kbClient, err := f.KubebuilderWatchClient() + // Create client with NonAdmin scheme + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + }) if err != nil { return err } - // Add NonAdminBackup types to the scheme - err = nacv1alpha1.AddToScheme(kbClient.Scheme()) - if err != nil { - return fmt.Errorf("failed to add NonAdminBackup types to scheme: %w", err) - } - o.client = kbClient // Always use the current namespace from kubectl context - currentNS, err := getCurrentNamespace() + currentNS, err := shared.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to determine current namespace: %w", err) } diff --git a/cmd/non-admin/backup/describe.go b/cmd/non-admin/backup/describe.go index 8d548c59..fa90109f 100644 --- a/cmd/non-admin/backup/describe.go +++ b/cmd/non-admin/backup/describe.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/migtools/oadp-cli/cmd/shared" nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" "github.com/spf13/cobra" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -18,7 +19,6 @@ import ( "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" kbclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -31,57 +31,137 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { backupName := args[0] // Get the current namespace from kubectl context - userNamespace, err := getCurrentNamespace() + userNamespace, err := shared.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to determine current namespace: %w", err) } - // Setup scheme and client for NonAdminBackup resources - scheme := runtime.NewScheme() - if err := nacv1alpha1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add OADP non-admin types to scheme: %w", err) - } - if err := velerov1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add Velero types to scheme: %w", err) - } - if err := corev1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add core v1 types to scheme: %w", err) - } - - restConfig, err := f.ClientConfig() + // Create client with required scheme types + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeNonAdminTypes: true, + IncludeVeleroTypes: true, + IncludeCoreTypes: true, + }) if err != nil { - return fmt.Errorf("failed to get rest config: %w", err) - } - - kbClient, err := kbclient.New(restConfig, kbclient.Options{Scheme: scheme}) - if err != nil { - return fmt.Errorf("failed to create controller-runtime client: %w", err) + return err } // Shows NonAdminBackup resources var nabList nacv1alpha1.NonAdminBackupList - if err := kbClient.List(context.TODO(), &nabList, kbclient.InNamespace(userNamespace)); err != nil { - return fmt.Errorf("failed to list NonAdminBackup resources: %w", err) + if err := kbClient.List(context.Background(), &nabList, &kbclient.ListOptions{ + Namespace: userNamespace, + }); err != nil { + return fmt.Errorf("failed to list NonAdminBackup: %w", err) } - // Finds the backup - var foundNAB *nacv1alpha1.NonAdminBackup + // Find the specific backup + var targetBackup *nacv1alpha1.NonAdminBackup for i := range nabList.Items { if nabList.Items[i].Name == backupName { - foundNAB = &nabList.Items[i] + targetBackup = &nabList.Items[i] break } } - if foundNAB == nil { + if targetBackup == nil { return fmt.Errorf("NonAdminBackup %q not found in namespace %q", backupName, userNamespace) } - return NonAdminDescribeBackup(cmd, kbClient, foundNAB, userNamespace) + // Print basic info + fmt.Printf("Name:\t%s\n", targetBackup.Name) + fmt.Printf("Namespace:\t%s\n", targetBackup.Namespace) + + // Print labels if any + if len(targetBackup.Labels) > 0 { + fmt.Printf("Labels:\t") + var labelPairs []string + for k, v := range targetBackup.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(labelPairs) + fmt.Printf("%s\n", strings.Join(labelPairs, ",")) + } else { + fmt.Printf("Labels:\t\n") + } + + // Print annotations if any + if len(targetBackup.Annotations) > 0 { + fmt.Printf("Annotations:\t") + var annotationPairs []string + for k, v := range targetBackup.Annotations { + annotationPairs = append(annotationPairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(annotationPairs) + fmt.Printf("%s\n", strings.Join(annotationPairs, ",")) + } else { + fmt.Printf("Annotations:\t\n") + } + + // Print phase/status + fmt.Printf("Phase:\t%s\n", targetBackup.Status.Phase) + + // Print conditions + if len(targetBackup.Status.Conditions) > 0 { + fmt.Printf("Conditions:\n") + for _, condition := range targetBackup.Status.Conditions { + fmt.Printf(" Type:\t%s\n", condition.Type) + fmt.Printf(" Status:\t%s\n", condition.Status) + if condition.Reason != "" { + fmt.Printf(" Reason:\t%s\n", condition.Reason) + } + if condition.Message != "" { + fmt.Printf(" Message:\t%s\n", condition.Message) + } + fmt.Printf(" Last Transition Time:\t%s\n", condition.LastTransitionTime.Format(time.RFC3339)) + fmt.Printf("\n") + } + } + + // Print related Velero backup info if available + if targetBackup.Status.VeleroBackup != nil { + fmt.Printf("Velero Backup:\n") + fmt.Printf(" Name:\t%s\n", targetBackup.Status.VeleroBackup.Name) + fmt.Printf(" Namespace:\t%s\n", targetBackup.Status.VeleroBackup.Namespace) + if targetBackup.Status.VeleroBackup.Status != nil { + fmt.Printf(" Status:\n") + // Print some key status fields + if targetBackup.Status.VeleroBackup.Status.Phase != "" { + fmt.Printf(" Phase:\t%s\n", targetBackup.Status.VeleroBackup.Status.Phase) + } + if !targetBackup.Status.VeleroBackup.Status.StartTimestamp.IsZero() { + fmt.Printf(" Start Time:\t%s\n", targetBackup.Status.VeleroBackup.Status.StartTimestamp.Format(time.RFC3339)) + } + if !targetBackup.Status.VeleroBackup.Status.CompletionTimestamp.IsZero() { + fmt.Printf(" Completion Time:\t%s\n", targetBackup.Status.VeleroBackup.Status.CompletionTimestamp.Format(time.RFC3339)) + } + if targetBackup.Status.VeleroBackup.Status.Expiration != nil { + fmt.Printf(" Expiration:\t%s\n", targetBackup.Status.VeleroBackup.Status.Expiration.Format(time.RFC3339)) + } + } + } + + // Print the spec (what was requested) + if targetBackup.Spec.BackupSpec != nil { + fmt.Printf("\nBackup Spec:\n") + specBytes, err := yaml.Marshal(targetBackup.Spec.BackupSpec) + if err != nil { + fmt.Printf(" Error marshaling spec: %v\n", err) + } else { + // Indent the YAML output + specLines := strings.Split(string(specBytes), "\n") + for _, line := range specLines { + if line != "" { + fmt.Printf(" %s\n", line) + } + } + } + } + + return nil }, - Example: ` # Describe a non-admin backup with detailed information - kubectl oadp nonadmin backup describe my-backup`, + Example: ` kubectl oadp nonadmin backup describe my-backup`, } + output.BindFlags(c.Flags()) output.ClearOutputFlagDefault(c) diff --git a/cmd/non-admin/backup/get.go b/cmd/non-admin/backup/get.go new file mode 100644 index 00000000..67ac40e2 --- /dev/null +++ b/cmd/non-admin/backup/get.go @@ -0,0 +1,152 @@ +/* +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 backup + +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 backup(s)", + Long: "Get one or more non-admin backups", + 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 backup + backupName := args[0] + var nab nacv1alpha1.NonAdminBackup + err := kbClient.Get(context.Background(), kbclient.ObjectKey{ + Namespace: userNamespace, + Name: backupName, + }, &nab) + if err != nil { + return fmt.Errorf("failed to get NonAdminBackup %q: %w", backupName, err) + } + + if printed, err := output.PrintWithFormat(cmd, &nab); printed || err != nil { + return err + } + + // If no output format specified, print table format for single item + list := &nacv1alpha1.NonAdminBackupList{ + Items: []nacv1alpha1.NonAdminBackup{nab}, + } + return printNonAdminBackupTable(list) + } else { + // List all backups in namespace + var nabList nacv1alpha1.NonAdminBackupList + err := kbClient.List(context.Background(), &nabList, &kbclient.ListOptions{ + Namespace: userNamespace, + }) + if err != nil { + return fmt.Errorf("failed to list NonAdminBackups: %w", err) + } + + if printed, err := output.PrintWithFormat(cmd, &nabList); printed || err != nil { + return err + } + + // Print table format + return printNonAdminBackupTable(&nabList) + } + }, + Example: ` # Get all non-admin backups in the current namespace + kubectl oadp nonadmin backup get + + # Get a specific non-admin backup + kubectl oadp nonadmin backup get my-backup + + # Get backups in YAML format + kubectl oadp nonadmin backup get -o yaml + + # Get a specific backup in JSON format + kubectl oadp nonadmin backup get my-backup -o json`, + } + + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +func printNonAdminBackupTable(nabList *nacv1alpha1.NonAdminBackupList) error { + if len(nabList.Items) == 0 { + fmt.Println("No non-admin backups found.") + return nil + } + + // Print header + fmt.Printf("%-30s %-15s %-20s %-10s\n", "NAME", "STATUS", "CREATED", "AGE") + + // Print each backup + for _, nab := range nabList.Items { + status := getBackupStatus(&nab) + created := nab.CreationTimestamp.Format("2006-01-02 15:04:05") + age := formatAge(nab.CreationTimestamp.Time) + + fmt.Printf("%-30s %-15s %-20s %-10s\n", nab.Name, status, created, age) + } + + return nil +} + +func getBackupStatus(nab *nacv1alpha1.NonAdminBackup) string { + if nab.Status.Phase != "" { + return string(nab.Status.Phase) + } + return "Unknown" +} + +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/backup/logs.go b/cmd/non-admin/backup/logs.go index 98c3efe7..a3ca02ab 100644 --- a/cmd/non-admin/backup/logs.go +++ b/cmd/non-admin/backup/logs.go @@ -1,5 +1,21 @@ package backup +/* +Copyright The Velero Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import ( "bufio" "compress/gzip" @@ -9,12 +25,12 @@ import ( "net/http" "time" + "github.com/migtools/oadp-cli/cmd/shared" nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" "github.com/spf13/cobra" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/client" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" kbclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -28,19 +44,21 @@ func NewLogsCommand(f client.Factory, use string) *cobra.Command { defer cancel() // Get the current namespace from kubectl context - userNamespace, err := getCurrentNamespace() + userNamespace, err := shared.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to determine current namespace: %w", err) } backupName := args[0] - scheme := runtime.NewScheme() - if err := nacv1alpha1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add OADP non-admin types to scheme: %w", err) - } - if err := velerov1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add Velero types to scheme: %w", err) + // 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) diff --git a/cmd/non-admin/backup/nonadminbackup_builder.go b/cmd/non-admin/backup/nonadminbackup_builder.go index 4f977a77..03e37cd6 100644 --- a/cmd/non-admin/backup/nonadminbackup_builder.go +++ b/cmd/non-admin/backup/nonadminbackup_builder.go @@ -17,11 +17,7 @@ limitations under the License. package backup import ( - "fmt" - "strings" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/clientcmd" nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" ) @@ -166,37 +162,3 @@ func WithAnnotationsMap(annotations map[string]string) ObjectMetaOpt { obj.SetAnnotations(existingAnnotations) } } - -// getCurrentNamespace gets the current namespace from the kubeconfig context -func getCurrentNamespace() (string, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{} - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - namespace, _, err := kubeConfig.Namespace() - if err != nil { - return "", fmt.Errorf("failed to get current namespace from kubeconfig: %w", err) - } - - // If no namespace is set in kubeconfig, default to the user's name from context - if namespace == "" || namespace == "default" { - rawConfig, err := kubeConfig.RawConfig() - if err != nil { - return "", fmt.Errorf("failed to get raw kubeconfig: %w", err) - } - - currentContext := rawConfig.CurrentContext - if _, exists := rawConfig.Contexts[currentContext]; exists { - // Try to extract user namespace from context name (assuming format like "user/cluster/user") - parts := strings.Split(currentContext, "/") - if len(parts) >= 3 { - userNamespace := parts[2] // Assuming the user namespace is the third part - return userNamespace, nil - } - } - - return "default", nil - } - - return namespace, nil -} diff --git a/cmd/non-admin/bsl/bsl.go b/cmd/non-admin/bsl/bsl.go new file mode 100644 index 00000000..3de5b796 --- /dev/null +++ b/cmd/non-admin/bsl/bsl.go @@ -0,0 +1,37 @@ +/* +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 bsl + +import ( + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/client" +) + +// NewBSLCommand creates the "bsl" subcommand under nonadmin +func NewBSLCommand(f client.Factory) *cobra.Command { + c := &cobra.Command{ + Use: "bsl", + Short: "Work with non-admin backup storage locations", + Long: "Work with non-admin backup storage locations", + } + + c.AddCommand( + NewCreateCommand(f, "create"), + ) + + return c +} diff --git a/cmd/non-admin/bsl/create.go b/cmd/non-admin/bsl/create.go new file mode 100644 index 00000000..5c571944 --- /dev/null +++ b/cmd/non-admin/bsl/create.go @@ -0,0 +1,154 @@ +/* +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 bsl + +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" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewCreateCommand(f client.Factory, use string) *cobra.Command { + o := NewCreateOptions() + + c := &cobra.Command{ + Use: use + " NAME", + Short: "Create a non-admin backup storage location", + 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 storage location + kubectl oadp nonadmin bsl create my-bsl --backup-storage-location default + + # Create a non-admin backup storage location with specific namespace + kubectl oadp nonadmin bsl create my-bsl --backup-storage-location aws-bsl --namespace my-namespace + + # Create with custom BSL namespace (if OADP operator is not in openshift-adp) + kubectl oadp nonadmin bsl create my-bsl --backup-storage-location default --bsl-namespace velero + + # View the YAML for a non-admin backup storage location without sending it to the server + kubectl oadp nonadmin bsl create my-bsl --backup-storage-location default -o yaml`, + } + + o.BindFlags(c.Flags()) + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +type CreateOptions struct { + Name string + BackupStorageLocation string + NonAdminNamespace string + BSLNamespace string + client kbclient.WithWatch +} + +func NewCreateOptions() *CreateOptions { + return &CreateOptions{ + BSLNamespace: "openshift-adp", // Default OADP operator namespace + } +} + +func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.BackupStorageLocation, "backup-storage-location", "", "Name of the BackupStorageLocation to reference.") + flags.StringVar(&o.NonAdminNamespace, "namespace", "", "Namespace for the NonAdminBackupStorageLocation (defaults to current context namespace).") + flags.StringVar(&o.BSLNamespace, "bsl-namespace", "openshift-adp", "Namespace where the BackupStorageLocation exists.") +} + +func (o *CreateOptions) Complete(args []string, f client.Factory) error { + o.Name = args[0] + + // Create client with Velero scheme for BackupStorageLocation access + client, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeVeleroTypes: true, + }) + if err != nil { + return err + } + + o.client = client + + if o.NonAdminNamespace == "" { + namespace := f.Namespace() + o.NonAdminNamespace = namespace + } + + return nil +} + +func (o *CreateOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { + if o.BackupStorageLocation == "" { + return fmt.Errorf("--backup-storage-location is required") + } + + return nil +} + +func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error { + // If we have a BackupStorageLocation name, we need to fetch its spec + var bslSpec *velerov1.BackupStorageLocationSpec + if o.BackupStorageLocation != "" { + // Get the existing BackupStorageLocation to copy its spec + existingBSL := &velerov1.BackupStorageLocation{} + err := o.client.Get(context.Background(), kbclient.ObjectKey{ + Name: o.BackupStorageLocation, + Namespace: o.BSLNamespace, // Use the BSLNamespace flag + }, existingBSL) + if err != nil { + return fmt.Errorf("failed to get BackupStorageLocation %q: %w", o.BackupStorageLocation, err) + } + bslSpec = &existingBSL.Spec + } + + bsl := &nacv1alpha1.NonAdminBackupStorageLocation{ + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + Namespace: o.NonAdminNamespace, + }, + Spec: nacv1alpha1.NonAdminBackupStorageLocationSpec{ + BackupStorageLocationSpec: bslSpec, + }, + } + + if printed, err := output.PrintWithFormat(c, bsl); printed || err != nil { + return err + } + + err := o.client.Create(context.Background(), bsl) + if err != nil { + return err + } + + fmt.Printf("NonAdminBackupStorageLocation %q created successfully.\n", bsl.Name) + return nil +} diff --git a/cmd/non-admin/nonadmin.go b/cmd/non-admin/nonadmin.go index 32c33b41..b42c1023 100644 --- a/cmd/non-admin/nonadmin.go +++ b/cmd/non-admin/nonadmin.go @@ -25,14 +25,18 @@ import ( // NewNonAdminCommand creates the top-level "nonadmin" subcommand 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", + Use: "nonadmin", + Short: "Work with non-admin resources", + Long: "Work with non-admin resources like backups and backup storage locations", + Aliases: []string{"na"}, } // Add backup subcommand c.AddCommand(backup.NewBackupCommand(f)) + // Add backup storage location subcommand + //c.AddCommand(bsl.NewBSLCommand(f)) + return c } diff --git a/cmd/shared/client.go b/cmd/shared/client.go new file mode 100644 index 00000000..f0f79906 --- /dev/null +++ b/cmd/shared/client.go @@ -0,0 +1,116 @@ +/* +Copyright 2025 The OADP CLI Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package shared + +import ( + "fmt" + + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/client" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ClientOptions holds configuration for creating Kubernetes clients +type ClientOptions struct { + // IncludeNonAdminTypes adds OADP NonAdmin CRD types to the scheme + IncludeNonAdminTypes bool + // IncludeVeleroTypes adds Velero CRD types to the scheme + IncludeVeleroTypes bool + // IncludeCoreTypes adds Kubernetes core types to the scheme + IncludeCoreTypes bool +} + +// NewClientWithScheme creates a controller-runtime client with the specified scheme types +func NewClientWithScheme(f client.Factory, opts ClientOptions) (kbclient.WithWatch, error) { + kbClient, err := f.KubebuilderWatchClient() + if err != nil { + return nil, fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + // Add schemes based on options + if opts.IncludeNonAdminTypes { + if err := nacv1alpha1.AddToScheme(kbClient.Scheme()); err != nil { + return nil, fmt.Errorf("failed to add OADP non-admin types to scheme: %w", err) + } + } + + if opts.IncludeVeleroTypes { + if err := velerov1.AddToScheme(kbClient.Scheme()); err != nil { + return nil, fmt.Errorf("failed to add Velero types to scheme: %w", err) + } + } + + if opts.IncludeCoreTypes { + if err := corev1.AddToScheme(kbClient.Scheme()); err != nil { + return nil, fmt.Errorf("failed to add Core types to scheme: %w", err) + } + } + + return kbClient, nil +} + +// NewClientWithFullScheme creates a client with all commonly used scheme types +func NewClientWithFullScheme(f client.Factory) (kbclient.WithWatch, error) { + return NewClientWithScheme(f, ClientOptions{ + IncludeNonAdminTypes: true, + IncludeVeleroTypes: true, + IncludeCoreTypes: true, + }) +} + +// NewSchemeWithTypes creates a new runtime scheme with the specified types +func NewSchemeWithTypes(opts ClientOptions) (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + + if opts.IncludeNonAdminTypes { + if err := nacv1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add OADP non-admin types to scheme: %w", err) + } + } + + if opts.IncludeVeleroTypes { + if err := velerov1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add Velero types to scheme: %w", err) + } + } + + if opts.IncludeCoreTypes { + if err := corev1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add Core types to scheme: %w", err) + } + } + + return scheme, nil +} + +// GetCurrentNamespace gets the current namespace from the kubeconfig context +func GetCurrentNamespace() (string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + namespace, _, err := kubeConfig.Namespace() + if err != nil { + return "", fmt.Errorf("failed to get current namespace from kubeconfig: %w", err) + } + + return namespace, nil +}