diff --git a/cmd/non-admin/backup/create.go b/cmd/non-admin/backup/create.go index cd182e01..40587915 100644 --- a/cmd/non-admin/backup/create.go +++ b/cmd/non-admin/backup/create.go @@ -252,7 +252,7 @@ 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). + return NewNonAdminBackupBuilder(namespace, o.Name). BackupSpec(nacv1alpha1.NonAdminBackupSpec{ BackupSpec: backupSpec, }). diff --git a/cmd/non-admin/backup/nonadminbackup_builder.go b/cmd/non-admin/backup/nonadminbackup_builder.go index 03e37cd6..cef8fdfe 100644 --- a/cmd/non-admin/backup/nonadminbackup_builder.go +++ b/cmd/non-admin/backup/nonadminbackup_builder.go @@ -44,8 +44,8 @@ type NonAdminBackupBuilder struct { object *nacv1alpha1.NonAdminBackup } -// ForNonAdminBackup is the constructor for a NonAdminBackupBuilder. -func ForNonAdminBackup(ns, name string) *NonAdminBackupBuilder { +// NewNonAdminBackupBuilder is the constructor for a NonAdminBackupBuilder. +func NewNonAdminBackupBuilder(namespace, name string) *NonAdminBackupBuilder { return &NonAdminBackupBuilder{ object: &nacv1alpha1.NonAdminBackup{ TypeMeta: metav1.TypeMeta{ @@ -53,7 +53,7 @@ func ForNonAdminBackup(ns, name string) *NonAdminBackupBuilder { Kind: "NonAdminBackup", }, ObjectMeta: metav1.ObjectMeta{ - Namespace: ns, + Namespace: namespace, Name: name, }, }, diff --git a/cmd/non-admin/restore/create.go b/cmd/non-admin/restore/create.go index 6bb59a5c..8d3e67a4 100644 --- a/cmd/non-admin/restore/create.go +++ b/cmd/non-admin/restore/create.go @@ -187,7 +187,7 @@ func (o *CreateOptions) BuildNonAdminRestore(namespace string) (*nacv1alpha1.Non tempRestore := restoreBuilder.Result() // Wrap in NonAdminRestore - return ForNonAdminRestore(namespace, o.Name). + return NewNonAdminRestoreBuilder(namespace, o.Name). RestoreSpec(nacv1alpha1.NonAdminRestoreSpec{ RestoreSpec: &tempRestore.Spec, }). diff --git a/cmd/non-admin/restore/nonadminrestore_builder.go b/cmd/non-admin/restore/nonadminrestore_builder.go index 6a5a67ad..23daa445 100644 --- a/cmd/non-admin/restore/nonadminrestore_builder.go +++ b/cmd/non-admin/restore/nonadminrestore_builder.go @@ -26,7 +26,7 @@ import ( Example usage: -var nonAdminRestore = builder.ForNonAdminRestore("user-namespace", "restore-1"). +var nonAdminRestore = builder.NewNonAdminRestoreBuilder("user-namespace", "restore-1"). ObjectMeta( builder.WithLabels("foo", "bar"), ). @@ -44,10 +44,10 @@ type NonAdminRestoreBuilder struct { object *nacv1alpha1.NonAdminRestore } -// ForNonAdminRestore is the constructor for a NonAdminRestoreBuilder. -func ForNonAdminRestore(ns, name string) *NonAdminRestoreBuilder { +// NewNonAdminRestoreBuilder is the constructor for a NonAdminRestoreBuilder. +func NewNonAdminRestoreBuilder(namespace, name string) *NonAdminRestoreBuilder { objMeta := metav1.ObjectMeta{ - Namespace: ns, + Namespace: namespace, } // If name is empty, use GenerateName for auto-generation diff --git a/cmd/root.go b/cmd/root.go index fb06af20..bbba4d15 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,6 +32,7 @@ import ( mustgather "github.com/migtools/oadp-cli/cmd/must-gather" "github.com/migtools/oadp-cli/cmd/nabsl-request" nonadmin "github.com/migtools/oadp-cli/cmd/non-admin" + "github.com/migtools/oadp-cli/cmd/setup" "github.com/spf13/cobra" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" clientcmd "github.com/vmware-tanzu/velero/pkg/client" @@ -450,11 +451,14 @@ func NewVeleroRootCommand(baseName string) *cobra.Command { // Must-gather command - diagnostic tool c.AddCommand(mustgather.NewMustGatherCommand(f)) + // Setup command - auto-detect and configure admin vs non-admin mode + c.AddCommand(setup.NewSetupCommand(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" || cmd.Use == "must-gather" { + if cmd.Use == "nonadmin" || cmd.Use == "nabsl-request" || cmd.Use == "must-gather" || cmd.Use == "setup" { continue } replaceVeleroWithOADP(cmd) @@ -472,6 +476,7 @@ func NewVeleroRootCommand(baseName string) *cobra.Command { "nonadmin": true, "client": true, "completion": true, + "setup": true, } for _, cmd := range c.Commands() { if !allowedCmds[cmd.Use] { diff --git a/cmd/setup/detector.go b/cmd/setup/detector.go new file mode 100644 index 00000000..c71cb79e --- /dev/null +++ b/cmd/setup/detector.go @@ -0,0 +1,68 @@ +/* +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 setup + +import ( + "fmt" + "os/exec" + "strings" +) + +// DetectionResult holds the result of detecting user mode +type DetectionResult struct { + IsAdmin bool + Error error +} + +// detectUserMode detects whether the user has admin permissions by checking +// if they can create Velero Backup resources across all namespaces. +// Admin users can create backups.velero.io cluster-wide, while non-admin users +// can only create nonadminbackups.oadp.openshift.io in their own namespace. +func detectUserMode() DetectionResult { + // Check if user can create Velero Backups across all namespaces + // This is the core permission difference between admin and non-admin modes + cmd := exec.Command("oc", "auth", "can-i", "create", "backups.velero.io", "--all-namespaces") + output, err := cmd.CombinedOutput() + + if err != nil { + // Check if this is because oc command failed vs permission check + if exitErr, ok := err.(*exec.ExitError); ok { + // Exit code 1 typically means "no" for can-i + if exitErr.ExitCode() == 1 { + return DetectionResult{IsAdmin: false} + } + } + // Check if output indicates not logged in + outputStr := string(output) + if strings.Contains(outputStr, "Unauthorized") || strings.Contains(outputStr, "not logged in") { + return DetectionResult{Error: fmt.Errorf("not logged in to cluster")} + } + // Other errors (oc not found, cluster unreachable, etc.) + return DetectionResult{Error: fmt.Errorf("failed to check permissions: %w", err)} + } + + // Parse the output + result := strings.TrimSpace(string(output)) + + // "yes" means user can create backups cluster-wide (admin mode) + if result == "yes" { + return DetectionResult{IsAdmin: true} + } + + // "no" means user cannot (non-admin mode) + return DetectionResult{IsAdmin: false} +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 00000000..fb3d4fac --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,207 @@ +/* +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 setup + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/migtools/oadp-cli/cmd/shared" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/vmware-tanzu/velero/pkg/client" +) + +// SetupOptions holds the options for the setup command +type SetupOptions struct { + Force bool // Re-run detection even if already configured + + // Internal state + detectionResult DetectionResult +} + +// BindFlags binds the flags to the command +func (o *SetupOptions) BindFlags(flags *pflag.FlagSet) { + flags.BoolVar(&o.Force, "force", false, "Re-run detection even if already configured") +} + +// Complete completes the options +func (o *SetupOptions) Complete(args []string, f client.Factory) error { + // No setup needed - detection uses oc CLI directly + return nil +} + +// Validate validates the options +func (o *SetupOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { + // No validation needed for setup command + return nil +} + +// Run executes the setup command +func (o *SetupOptions) Run(c *cobra.Command, f client.Factory) error { + fmt.Println("Detecting user permissions...") + fmt.Println() + + // Silence usage help on errors during Run (we provide clear error messages) + c.SilenceUsage = true + + // Check if already configured (unless --force flag set) + if !o.Force { + existingConfig, err := shared.ReadVeleroClientConfig() + if err != nil { + return fmt.Errorf("failed to read existing config: %w", err) + } + + // Check if nonadmin field is explicitly set (not nil) + if existingConfig.NonAdmin != nil { + fmt.Println("OADP CLI is already configured.") + fmt.Println() + o.printCurrentConfig(existingConfig) + fmt.Println() + fmt.Println("To reconfigure, run: oc oadp setup --force") + return nil + } + } + + // Run detection + o.detectionResult = detectUserMode() + + // Handle detection errors + if o.detectionResult.Error != nil { + // Provide specific guidance based on error type + errMsg := o.detectionResult.Error.Error() + if strings.Contains(errMsg, "not logged in") || strings.Contains(errMsg, "Unauthorized") { + fmt.Println("Error: Not logged in to cluster") + fmt.Println() + fmt.Println("Please log in to your cluster:") + fmt.Println(" oc login ") + return fmt.Errorf("not logged in to cluster") + } else { + fmt.Printf("Error: %v\n", o.detectionResult.Error) + fmt.Println() + fmt.Println("This could mean:") + fmt.Println(" - Your cluster is not accessible") + fmt.Println(" - Your kubeconfig is invalid") + fmt.Println(" - Network connectivity issues") + return o.detectionResult.Error + } + } + + // Read existing config to preserve fields like default-nabsl + config, err := shared.ReadVeleroClientConfig() + if err != nil { + return fmt.Errorf("failed to read existing config: %w", err) + } + + // Update config based on detection result + if o.detectionResult.IsAdmin { + config.NonAdmin = false + } else { + config.NonAdmin = true + } + + // Write config file + if err := shared.WriteVeleroClientConfig(config); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + // Print success message + o.printSetupSuccess() + + return nil +} + +// printCurrentConfig prints the current configuration +func (o *SetupOptions) printCurrentConfig(config *shared.ClientConfig) { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".config", "velero", "config.json") + + if config.IsNonAdmin() { + fmt.Println("Current mode: non-admin") + } else { + fmt.Println("Current mode: admin") + } + fmt.Printf("Configuration file: %s\n", configPath) +} + +// printSetupSuccess prints a success message after setup +func (o *SetupOptions) printSetupSuccess() { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".config", "velero", "config.json") + + if o.detectionResult.IsAdmin { + fmt.Println("✓ Admin mode enabled") + fmt.Println() + fmt.Printf("Configuration saved to: %s\n", configPath) + fmt.Println() + fmt.Println("You can now use OADP admin commands:") + fmt.Println(" oc oadp backup create my-backup") + fmt.Println(" oc oadp restore create my-restore") + } else { + fmt.Println("✓ Non-admin mode enabled") + fmt.Println() + fmt.Printf("Configuration saved to: %s\n", configPath) + fmt.Println() + fmt.Println("You can now use OADP non-admin commands:") + fmt.Println(" oc oadp nonadmin backup create my-backup") + fmt.Println(" oc oadp nonadmin restore create my-restore") + } +} + +// NewSetupCommand creates the setup command +func NewSetupCommand(f client.Factory) *cobra.Command { + o := &SetupOptions{} + + c := &cobra.Command{ + Use: "setup", + Short: "Auto-detect and configure admin vs non-admin mode", + Long: `Auto-detect and configure admin vs non-admin mode. + +This command detects whether you have cluster-wide admin permissions and +automatically configures the OADP CLI to use the appropriate mode: + +- Admin mode: Can create Velero Backup resources across all namespaces +- Non-admin mode: Can only create NonAdminBackup resources in current namespace + +The detection works by checking RBAC permissions: oc auth can-i create backups.velero.io --all-namespaces + +Configuration is saved to: ~/.config/velero/config.json + +Examples: + # Auto-detect and configure OADP CLI + oc oadp setup + + # Re-run detection (reconfigure) + oc oadp setup --force`, + Args: cobra.ExactArgs(0), + RunE: func(c *cobra.Command, args []string) error { + if err := o.Complete(args, f); err != nil { + return err + } + if err := o.Validate(c, args, f); err != nil { + return err + } + return o.Run(c, f) + }, + } + + o.BindFlags(c.Flags()) + + return c +} diff --git a/cmd/shared/factories.go b/cmd/shared/factories.go index 20a6eca7..59cc1725 100644 --- a/cmd/shared/factories.go +++ b/cmd/shared/factories.go @@ -112,3 +112,88 @@ func ReadVeleroClientConfig() (*ClientConfig, error) { return &config, nil } + +// getVeleroConfigPath returns the path to the Velero client config file +func getVeleroConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(homeDir, ".config", "velero", "config.json"), nil +} + +// readConfigMap reads the existing config file as a map, or returns an empty map if it doesn't exist +func readConfigMap(configPath string) (map[string]interface{}, error) { + configMap := make(map[string]interface{}) + + existingData, err := os.ReadFile(configPath) + if err == nil { + // File exists, unmarshal it + if err := json.Unmarshal(existingData, &configMap); err != nil { + return nil, fmt.Errorf("failed to parse existing config: %w", err) + } + } else if !os.IsNotExist(err) { + // Error other than file not existing + return nil, fmt.Errorf("failed to read existing config: %w", err) + } + // If file doesn't exist, configMap remains empty + + return configMap, nil +} + +// mergeClientConfig merges the ClientConfig into the config map, updating only managed keys +func mergeClientConfig(configMap map[string]interface{}, config *ClientConfig) { + configMap["namespace"] = config.Namespace + + if config.NonAdmin != nil { + configMap["nonadmin"] = config.NonAdmin + } else { + delete(configMap, "nonadmin") + } + + if config.DefaultNABSL != "" { + configMap["default-nabsl"] = config.DefaultNABSL + } else { + delete(configMap, "default-nabsl") + } +} + +// writeConfigMap writes the config map to the specified path +func writeConfigMap(configPath string, configMap map[string]interface{}) error { + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(configMap, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Write to file + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// WriteVeleroClientConfig writes the client configuration to ~/.config/velero/config.json +// It merges only the keys managed by this CLI (namespace, nonadmin, default-nabsl) +// with the existing config file, preserving any other Velero configuration keys. +func WriteVeleroClientConfig(config *ClientConfig) error { + configPath, err := getVeleroConfigPath() + if err != nil { + return err + } + + configMap, err := readConfigMap(configPath) + if err != nil { + return err + } + + mergeClientConfig(configMap, config) + + return writeConfigMap(configPath, configMap) +}