diff --git a/cmd/non-admin/backup/backup.go b/cmd/non-admin/backup/backup.go index 62992293..38f00ca9 100644 --- a/cmd/non-admin/backup/backup.go +++ b/cmd/non-admin/backup/backup.go @@ -32,6 +32,8 @@ func NewBackupCommand(f client.Factory) *cobra.Command { c.AddCommand( NewCreateCommand(f, "create"), + NewLogsCommand(f, "logs"), + NewDescribeCommand(f, "describe"), NewDeleteCommand(f, "delete"), ) diff --git a/cmd/non-admin/backup/describe.go b/cmd/non-admin/backup/describe.go new file mode 100644 index 00000000..38c26bc4 --- /dev/null +++ b/cmd/non-admin/backup/describe.go @@ -0,0 +1,325 @@ +package backup + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/spf13/cobra" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/client" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" + "gopkg.in/yaml.v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewDescribeCommand(f client.Factory, use string) *cobra.Command { + c := &cobra.Command{ + Use: use + " NAME", + Short: "Describe a non-admin backup", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + backupName := args[0] + userNamespace := f.Namespace() + + // 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) + } + + restConfig, err := f.ClientConfig() + if err != nil { + return fmt.Errorf("failed to get rest config: %w", err) + } + + kbClient, err := kbclient.New(restConfig, kbclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + // 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) + } + + // Finds the backup + var foundNAB *nacv1alpha1.NonAdminBackup + for i := range nabList.Items { + if nabList.Items[i].Name == backupName { + foundNAB = &nabList.Items[i] + break + } + } + + if foundNAB == nil { + return fmt.Errorf("NonAdminBackup %q not found in namespace %q", backupName, userNamespace) + } + + return NonAdminDescribeBackup(cmd, kbClient, foundNAB, userNamespace) + }, + Example: ` # Describe a non-admin backup with detailed information + kubectl oadp nonadmin backup describe my-backup`, + } + output.BindFlags(c.Flags()) + output.ClearOutputFlagDefault(c) + + return c +} + +// NonAdminDescribeBackup mirrors Velero's output.DescribeBackup functionality +// but works within non-admin RBAC boundaries using NonAdminDownloadRequest +func NonAdminDescribeBackup(cmd *cobra.Command, kbClient kbclient.Client, nab *nacv1alpha1.NonAdminBackup, userNamespace string) error { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Print basic backup information + fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", nab.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", nab.Namespace) + + // Print labels + fmt.Fprintf(cmd.OutOrStdout(), "Labels:\n") + if len(nab.Labels) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), " \n") + } else { + labelKeys := make([]string, 0, len(nab.Labels)) + for k := range nab.Labels { + labelKeys = append(labelKeys, k) + } + sort.Strings(labelKeys) + for _, k := range labelKeys { + fmt.Fprintf(cmd.OutOrStdout(), " %s=%s\n", k, nab.Labels[k]) + } + } + + // Print annotations + fmt.Fprintf(cmd.OutOrStdout(), "Annotations:\n") + if len(nab.Annotations) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), " \n") + } else { + annotationKeys := make([]string, 0, len(nab.Annotations)) + for k := range nab.Annotations { + annotationKeys = append(annotationKeys, k) + } + sort.Strings(annotationKeys) + for _, k := range annotationKeys { + fmt.Fprintf(cmd.OutOrStdout(), " %s=%s\n", k, nab.Annotations[k]) + } + } + + // Print timestamps and status from NonAdminBackup + fmt.Fprintf(cmd.OutOrStdout(), "Creation Timestamp: %s\n", nab.CreationTimestamp.Format(time.RFC3339)) + fmt.Fprintf(cmd.OutOrStdout(), "Phase: %s\n", nab.Status.Phase) + + // If there's a referenced Velero backup, get more details + if nab.Status.VeleroBackup != nil && nab.Status.VeleroBackup.Name != "" { + veleroBackupName := nab.Status.VeleroBackup.Name + + // Get backup details using NonAdminDownloadRequest for BackupResourceList + if resourceList, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResourceList"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Resource List:\n") + fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(resourceList, " ")) + } + + // Get backup volume info using NonAdminDownloadRequest + if volumeInfo, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupVolumeInfos"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Volume Info:\n") + fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(volumeInfo, " ")) + } + + // Get backup item operations using NonAdminDownloadRequest + if itemOps, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupItemOperations"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Item Operations:\n") + fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(itemOps, " ")) + } + + // Get backup results using NonAdminDownloadRequest + if results, err := downloadBackupData(ctx, kbClient, userNamespace, veleroBackupName, "BackupResults"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nBackup Results:\n") + fmt.Fprintf(cmd.OutOrStdout(), "%s", indent(results, " ")) + } + } + + // Print NonAdminBackup Spec (excluding sensitive information) + if nab.Spec.BackupSpec != nil { + specYaml, err := yaml.Marshal(nab.Spec.BackupSpec) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nSpec: \n", err) + } else { + lines := strings.Split(string(specYaml), "\n") + var filtered []string + skip := false + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if !skip && (strings.HasPrefix(trimmed, "includedNamespaces:") || strings.HasPrefix(trimmed, "includednamespaces:")) { + skip = true + continue + } + if skip { + // Skip all list items or indented lines after the key + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") || trimmed == "" { + continue + } else { + // Found a new top-level key, stop skipping + skip = false + } + } + if !skip { + filtered = append(filtered, line) + } + } + fmt.Fprintf(cmd.OutOrStdout(), "\nSpec:\n%s", indent(strings.Join(filtered, "\n"), " ")) + } + } + + // Print NonAdminBackup Status + statusYaml, err := yaml.Marshal(nab.Status) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "\nStatus: \n", err) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "\nStatus:\n%s", indent(string(statusYaml), " ")) + } + + // Print Events for NonAdminBackup + fmt.Fprintf(cmd.OutOrStdout(), "\nEvents:\n") + var eventList corev1.EventList + if err := kbClient.List(ctx, &eventList, kbclient.InNamespace(userNamespace)); err != nil { + fmt.Fprintf(cmd.OutOrStdout(), " \n", err) + } else { + // Filter events related to this NonAdminBackup + var relatedEvents []corev1.Event + for _, event := range eventList.Items { + if event.InvolvedObject.Kind == "NonAdminBackup" && event.InvolvedObject.Name == nab.Name { + relatedEvents = append(relatedEvents, event) + } + } + + if len(relatedEvents) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), " \n") + } else { + for _, e := range relatedEvents { + fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", e.Reason, e.Message) + } + } + } + + return nil +} + +// downloadBackupData uses NonAdminDownloadRequest to fetch detailed backup information +// This replaces direct access to Velero backups with RBAC-compliant requests +func downloadBackupData(ctx context.Context, kbClient kbclient.Client, userNamespace, backupName, dataType string) (string, error) { + // Create NonAdminDownloadRequest for the specified data type + req := &nacv1alpha1.NonAdminDownloadRequest{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: backupName + "-" + strings.ToLower(dataType) + "-", + Namespace: userNamespace, + }, + Spec: nacv1alpha1.NonAdminDownloadRequestSpec{ + Target: velerov1.DownloadTarget{ + Kind: velerov1.DownloadTargetKind(dataType), + Name: backupName, + }, + }, + } + + if err := kbClient.Create(ctx, req); err != nil { + return "", fmt.Errorf("failed to create NonAdminDownloadRequest for %s: %w", dataType, err) + } + + // Clean up the download request when done + defer func() { + deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelDelete() + _ = kbClient.Delete(deleteCtx, req) + }() + + // Wait for the download request to be processed + timeout := time.After(30 * time.Second) + tick := time.Tick(1 * time.Second) + + for { + select { + case <-timeout: + return "", fmt.Errorf("timed out waiting for %s download request to be processed", dataType) + case <-tick: + var updated nacv1alpha1.NonAdminDownloadRequest + if err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: req.Namespace, + Name: req.Name, + }, &updated); err != nil { + return "", fmt.Errorf("failed to get NonAdminDownloadRequest: %w", err) + } + + switch updated.Status.Phase { + case "Processed": + if updated.Status.VeleroDownloadRequest.Status.DownloadURL != "" { + // Download and return the content + return downloadContent(updated.Status.VeleroDownloadRequest.Status.DownloadURL) + } + case "Failed": + return "", fmt.Errorf("NonAdminDownloadRequest failed for %s: phase=%s", dataType, updated.Status.Phase) + default: + // Continue waiting + } + } + } +} + +// downloadContent fetches content from a signed URL and returns it as a string +func downloadContent(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to download content from URL %q: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to download content: status %s, body: %s", resp.Status, string(bodyBytes)) + } + + // Try to decompress if it's gzipped + var reader io.Reader = resp.Body + if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + reader = gzr + } + + // Read all content + content, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read content: %w", err) + } + + return string(content), nil +} + +// Helper to 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/backup/logs.go b/cmd/non-admin/backup/logs.go new file mode 100644 index 00000000..3a57f555 --- /dev/null +++ b/cmd/non-admin/backup/logs.go @@ -0,0 +1,131 @@ +package backup + +import ( + "bufio" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "time" + + 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" +) + +func NewLogsCommand(f client.Factory, use string) *cobra.Command { + return &cobra.Command{ + Use: use + " NAME", + Short: "Show logs for a non-admin backup", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + userNamespace := f.Namespace() + 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) + } + restConfig, err := f.ClientConfig() + if err != nil { + return fmt.Errorf("failed to get rest config: %w", err) + } + kbClient, err := kbclient.New(restConfig, kbclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + req := &nacv1alpha1.NonAdminDownloadRequest{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: backupName + "-logs-", + Namespace: userNamespace, + }, + Spec: nacv1alpha1.NonAdminDownloadRequestSpec{ + Target: velerov1.DownloadTarget{ + Kind: "BackupLog", + Name: backupName, + }, + }, + } + + if err := kbClient.Create(ctx, req); err != nil { + return fmt.Errorf("failed to create NonAdminDownloadRequest: %w", err) + } + + defer func() { + deleteCtx, cancelDelete := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelDelete() + _ = kbClient.Delete(deleteCtx, req) + }() + + var signedURL string + timeout := time.After(60 * time.Second) + tick := time.Tick(1 * time.Second) + Loop: + for { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for NonAdminDownloadRequest to be processed") + case <-tick: + var updated nacv1alpha1.NonAdminDownloadRequest + if err := kbClient.Get(ctx, kbclient.ObjectKey{ + Namespace: req.Namespace, + Name: req.Name, + }, &updated); err != nil { + return fmt.Errorf("failed to get NonAdminDownloadRequest: %w", err) + } + + switch updated.Status.Phase { + case "Processed": + if updated.Status.VeleroDownloadRequest.Status.DownloadURL != "" { + signedURL = updated.Status.VeleroDownloadRequest.Status.DownloadURL + break Loop + } + case "Failed": + return fmt.Errorf("NonAdminDownloadRequest failed: phase=%s", updated.Status.Phase) + default: + } + } + } + + resp, err := http.Get(signedURL) + if err != nil { + return fmt.Errorf("failed to download logs from URL %q: %w", signedURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to download logs: status %s, body: %s", resp.Status, string(bodyBytes)) + } + + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + scanner := bufio.NewScanner(gzr) + for scanner.Scan() { + fmt.Fprintln(cmd.OutOrStdout(), scanner.Text()) + } + if err := scanner.Err(); err != nil && err != io.EOF { + return fmt.Errorf("failed to read logs: %w", err) + } + + return nil + }, + Example: ` kubectl oadp backup logs my-backup`, + } +} diff --git a/go.mod b/go.mod index e098bdf9..80271e4a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/vmware-tanzu/velero v1.16.1 + gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.33.1 k8s.io/apimachinery v0.33.1 k8s.io/client-go v0.33.1 sigs.k8s.io/controller-runtime v0.19.3 @@ -87,7 +89,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.33.1 // indirect k8s.io/apiextensions-apiserver v0.31.3 // indirect k8s.io/cli-runtime v0.31.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/kubectl-oadp-design.md b/kubectl-oadp-design.md new file mode 100644 index 00000000..5f18eb9f --- /dev/null +++ b/kubectl-oadp-design.md @@ -0,0 +1,79 @@ +# Kubectl-oadp plugin design + +## Abstract +The purpose of the kubectl-oadp plugin is to allow the customer to create and delete backups, along with creating restores in OADP without needing to alias velero to do so. Non-cluster admins should also be able to create NABs (Non-Admin Backups) and get the logs from them. + +## Background +The current OpenShift cli is suboptimal as oc backup delete $foo deletes the k8s object instead of the backup but velero backup delete $foo deletes the backup, along with the backup files in storage. Currently, customers would need to alias velero in order to delete their backups, which is not ideal. The purpose of kubectl-oadp would be to make the cli experience better and easier to use along with enabling users to be able to get the logs of the backups. + +## Goals +- Customers can create, delete, and get the logs of the backups and restores +- A non-cluster admin can create, delete and receive the logs of the Non-Admin-Backups (NAB) + +## Non-Goals +- Non-Admin-Restore and other Non-Admin CRs due to time constraints + +## Use-Case +A use case of the kubectl-oadp cli could be when a non-cluster admin would like to create a NAB or view the logs of a NAB without having to depend on the cluster admins to do so. Another use case would be if a developer would want to create a normal backup, they can just use this plugin to do so. + +## High-Level Design +Creating a kubectl plugin (kubectl-oadp) will be a good solution to the problem at hand. It will be able to create/delete backups and restores utilizing the go package imports. Non-cluster admin will be able to create NABs without the need for cluster admin to do it for them. A way to distinguish between creating either NABs or regular backups would be in the cli using the non-admin api. For instance, if you would like to create a NAB, you would have to do kubectl oadp nonadmin backup create [backupname]. + +## Detailed Design +The kubectl plugin will have imports from velero to help with the creation/deletion of backups and restores. It will be written in Golang and be using cobra for command-line parsing. The non-admin cli can be a subset of some backup clis that already exist such as backup.go and create.go. + +What we discovered with the regular commands such as version, backup, and restore we can just import the libraries + +```go +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vmware-tanzu/velero/pkg/cmd/cli/backup" + "github.com/vmware-tanzu/velero/pkg/cmd/cli/restore" + "github.com/vmware-tanzu/velero/pkg/cmd/cli/version" +) +``` +With non-admin, we would have to create the cli ourselves since there are no CLIs for it. + +```go +func NewCreateCommand(f client.Factory, use string) *cobra.Command { + o := NewCreateOptions() + + c := &cobra.Command{ + Use: use + " NAME", + Short: "Create a non-admin backup", + 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)) + }, + +``` +CLI Examples +```sh +kubectl oadp backup create +kubectl oadp backup delete +kubectl oadp nonadmin backup create +kubectl oadp nonadmin backup logs +kubectl oadp nonadmin backup describe +kubectl oadp restore create +``` + +## Alternatives Considered +An alternative that was considered was creating our own CLI from scratch and not using a plugin. We can instead use the existing oc commands and just add on to them with a kubectl plugin. + +Aliasing is another way in which you could access the velero command line. However, this is not ideal because non admins do not have permission to use the velero cli, so the kubectl plugin would allow non admins to retrieve their non admin backups similar to admins via velero cli. + +## Security Considerations +The security for the plugin is controlled by OpenShift RBAC, enabling cluster admins to manage user permissions. This is utilized to restrict users to commands permitted within their namespace. The plugin also generates error messages like "Unauthorized Access" when users attempt commands without proper permissions. + +## Compatibility +This plugin would need to be updated so that it would be importing the right version of the velero backup and restore libraries. + +## Future Work +Some future work that could be expanded upon would be Non-admin Restores, and other Non-admin CRs such as NonAdminBackupStorageLocation. These would allow more options for those who would like to use different non admin commands.