From 8bca7b773c04c04b08c69fe874967b8c900fbb13 Mon Sep 17 00:00:00 2001 From: Joseph Date: Thu, 5 Mar 2026 15:09:53 -0500 Subject: [PATCH 1/6] Add oadp setup command to auto-detect admin vs non-admin mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #141 by adding an `oadp setup` command that automatically detects whether the user has cluster-wide admin permissions and configures the CLI accordingly. Detection strategy: - Attempts to list deployments across all namespaces - If successful and finds openshift-adp-controller-manager → admin mode - If permission denied/forbidden → non-admin mode - If unauthorized (not logged in) → clear error with instructions Key features: - Auto-detects and configures nonadmin setting - Preserves existing config fields (default-nabsl, etc) - Available in both admin and non-admin modes - --force flag to re-run detection - Clear user feedback with next steps Files added: - cmd/setup/setup.go - Main setup command implementation - cmd/setup/detector.go - Permission detection logic Files modified: - cmd/shared/factories.go - Added WriteVeleroClientConfig() and OADPNamespace field - cmd/root.go - Registered setup command, added to allowed commands Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Joseph --- cmd/root.go | 7 +- cmd/setup/detector.go | 72 ++++++++++++ cmd/setup/setup.go | 247 ++++++++++++++++++++++++++++++++++++++++ cmd/shared/factories.go | 35 +++++- 4 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 cmd/setup/detector.go create mode 100644 cmd/setup/setup.go 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..26552f72 --- /dev/null +++ b/cmd/setup/detector.go @@ -0,0 +1,72 @@ +/* +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 ( + "context" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/errors" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DetectionResult holds the result of detecting user mode and OADP installation +type DetectionResult struct { + IsAdmin bool + OADPNamespace string + Error error +} + +// detectUserMode detects whether the user has admin permissions and finds the OADP namespace. +// It does this by attempting to list deployments across all namespaces and looking for +// "openshift-adp-controller-manager". Admin users can see resources across all namespaces, +// while non-admin users cannot. +func detectUserMode(ctx context.Context, client kbclient.Client) DetectionResult { + deployments := &appsv1.DeploymentList{} + + // Attempt to list all deployments across all namespaces. + // This will fail with permission denied for non-admin users. + err := client.List(ctx, deployments) + + if err != nil { + // Check if not logged in - this is a fatal error + if errors.IsUnauthorized(err) { + return DetectionResult{Error: fmt.Errorf("not logged in to cluster: %w", err)} + } + // Check if permission denied - indicates non-admin user + if errors.IsForbidden(err) { + return DetectionResult{IsAdmin: false} + } + // Other errors (cluster unreachable, etc.) + return DetectionResult{Error: fmt.Errorf("failed to query cluster: %w", err)} + } + + // Filter deployments to find OADP controller + for _, deployment := range deployments.Items { + if deployment.Name == "openshift-adp-controller-manager" { + // Found OADP controller - user is admin + return DetectionResult{ + IsAdmin: true, + OADPNamespace: deployment.Namespace, + } + } + } + + // OADP not installed - default to non-admin + return DetectionResult{IsAdmin: false} +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 00000000..8f324ee1 --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,247 @@ +/* +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 ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/migtools/oadp-cli/cmd/shared" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/vmware-tanzu/velero/pkg/client" + appsv1 "k8s.io/api/apps/v1" + kbclient "sigs.k8s.io/controller-runtime/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 + kbClient kbclient.Client +} + +// 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 { + // Create Kubernetes client with apps/v1 types for deployment detection + kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ + IncludeCoreTypes: true, + Timeout: 10 * time.Second, // Prevent hanging on cluster connection issues + }) + if err != nil { + // Check if this is an authentication error + if strings.Contains(err.Error(), "Unauthorized") { + return fmt.Errorf("not logged in to cluster. Please run: oc login ") + } + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Add apps/v1 types to the scheme for deployment access + if err := appsv1.AddToScheme(kbClient.Scheme()); err != nil { + return fmt.Errorf("failed to add apps/v1 types to scheme: %w", err) + } + + o.kbClient = kbClient + 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 OADP configuration...") + 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 + ctx := context.Background() + o.detectionResult = detectUserMode(ctx, o.kbClient) + + // 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 + config.OADPNamespace = o.detectionResult.OADPNamespace + // Set namespace to OADP namespace for admin mode + if config.Namespace == "" { + config.Namespace = o.detectionResult.OADPNamespace + } + } else { + config.NonAdmin = true + // Don't set OADP namespace for non-admin users + config.OADPNamespace = "" + } + + // Write config file + if err := shared.WriteVeleroClientConfig(config); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + // Print success message + o.printSetupSuccess(config) + + 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") + if config.OADPNamespace != "" { + fmt.Printf("OADP namespace: %s\n", config.OADPNamespace) + } + } + fmt.Printf("Configuration file: %s\n", configPath) +} + +// printSetupSuccess prints a success message after setup +func (o *SetupOptions) printSetupSuccess(config *shared.ClientConfig) { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".config", "velero", "config.json") + + if o.detectionResult.IsAdmin { + fmt.Printf("✓ Found OADP controller in namespace: %s\n", o.detectionResult.OADPNamespace) + 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") + fmt.Println() + fmt.Println("Note: OADP controller deployment not found or you don't have") + fmt.Println("cluster-wide permissions. Non-admin mode uses namespace-scoped resources.") + } +} + +// 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: Full access to OADP resources across all namespaces +- Non-admin mode: Namespace-scoped access using NonAdminBackup resources + +The detection works by checking if you can list the OADP controller deployment +across all namespaces. Admin users can see resources cluster-wide, while +non-admin users are limited to their current namespace. + +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..1311abbc 100644 --- a/cmd/shared/factories.go +++ b/cmd/shared/factories.go @@ -28,9 +28,10 @@ import ( // ClientConfig represents the structure of the Velero client configuration file type ClientConfig struct { - Namespace string `json:"namespace"` - NonAdmin interface{} `json:"nonadmin,omitempty"` - DefaultNABSL string `json:"default-nabsl,omitempty"` + Namespace string `json:"namespace"` + NonAdmin interface{} `json:"nonadmin,omitempty"` + DefaultNABSL string `json:"default-nabsl,omitempty"` + OADPNamespace string `json:"oadp_namespace,omitempty"` } // IsNonAdmin returns true if the nonadmin configuration is enabled. @@ -112,3 +113,31 @@ func ReadVeleroClientConfig() (*ClientConfig, error) { return &config, nil } + +// WriteVeleroClientConfig writes the client configuration to ~/.config/velero/config.json +func WriteVeleroClientConfig(config *ClientConfig) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + configPath := filepath.Join(homeDir, ".config", "velero", "config.json") + + // 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(config, "", " ") + 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 +} From 8531fafb5ee82aef5f6d852fbacf45658017037d Mon Sep 17 00:00:00 2001 From: Joseph Date: Thu, 5 Mar 2026 15:12:27 -0500 Subject: [PATCH 2/6] Refactor: Rename builder constructors to follow Go naming conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed builder constructors from For* pattern to New*Builder pattern to align with Go best practices where constructors should start with "New". Changes: - ForNonAdminBackup → NewNonAdminBackupBuilder - ForNonAdminRestore → NewNonAdminRestoreBuilder - Renamed parameter ns → namespace for clarity Updated all usages in create.go files and example documentation. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Joseph --- cmd/non-admin/backup/create.go | 2 +- cmd/non-admin/backup/nonadminbackup_builder.go | 6 +++--- cmd/non-admin/restore/create.go | 2 +- cmd/non-admin/restore/nonadminrestore_builder.go | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) 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 From 12786d64384cf235193386f9a3b717df52948f50 Mon Sep 17 00:00:00 2001 From: Joseph Date: Thu, 5 Mar 2026 15:19:20 -0500 Subject: [PATCH 3/6] Add debug message for non-admin mode setup Mention that the OADP controller deployment is not accessible for non-admin users. Signed-off-by: Joseph --- cmd/setup/setup.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8f324ee1..ac781d74 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -189,6 +189,7 @@ func (o *SetupOptions) printSetupSuccess(config *shared.ClientConfig) { fmt.Println(" oc oadp backup create my-backup") fmt.Println(" oc oadp restore create my-restore") } else { + fmt.Println("✗ OADP controller deployment not accessible.") fmt.Println("✓ Non-admin mode enabled") fmt.Println() fmt.Printf("Configuration saved to: %s\n", configPath) From 046fe65ad2bf6f2f16d1cb758cb76a67ad263b50 Mon Sep 17 00:00:00 2001 From: Joseph Date: Thu, 5 Mar 2026 15:46:03 -0500 Subject: [PATCH 4/6] Remove unused param Signed-off-by: Joseph --- cmd/setup/setup.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index ac781d74..a17337ad 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -153,7 +153,7 @@ func (o *SetupOptions) Run(c *cobra.Command, f client.Factory) error { } // Print success message - o.printSetupSuccess(config) + o.printSetupSuccess() return nil } @@ -175,7 +175,7 @@ func (o *SetupOptions) printCurrentConfig(config *shared.ClientConfig) { } // printSetupSuccess prints a success message after setup -func (o *SetupOptions) printSetupSuccess(config *shared.ClientConfig) { +func (o *SetupOptions) printSetupSuccess() { homeDir, _ := os.UserHomeDir() configPath := filepath.Join(homeDir, ".config", "velero", "config.json") From 99cea2919b48efb4ca85241aa70d2e1408d359d8 Mon Sep 17 00:00:00 2001 From: Joseph Date: Fri, 6 Mar 2026 10:05:01 -0500 Subject: [PATCH 5/6] Change detection logic to use can-i command Detect if user has admin permissions by checking if they can create Velero Backup resources across all namespaces. Signed-off-by: Joseph --- cmd/setup/detector.go | 70 +++++++++++++++++++---------------------- cmd/setup/setup.go | 53 ++++--------------------------- cmd/shared/factories.go | 7 ++--- 3 files changed, 42 insertions(+), 88 deletions(-) diff --git a/cmd/setup/detector.go b/cmd/setup/detector.go index 26552f72..c71cb79e 100644 --- a/cmd/setup/detector.go +++ b/cmd/setup/detector.go @@ -17,56 +17,52 @@ limitations under the License. package setup import ( - "context" "fmt" - - appsv1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/api/errors" - kbclient "sigs.k8s.io/controller-runtime/pkg/client" + "os/exec" + "strings" ) -// DetectionResult holds the result of detecting user mode and OADP installation +// DetectionResult holds the result of detecting user mode type DetectionResult struct { - IsAdmin bool - OADPNamespace string - Error error + IsAdmin bool + Error error } -// detectUserMode detects whether the user has admin permissions and finds the OADP namespace. -// It does this by attempting to list deployments across all namespaces and looking for -// "openshift-adp-controller-manager". Admin users can see resources across all namespaces, -// while non-admin users cannot. -func detectUserMode(ctx context.Context, client kbclient.Client) DetectionResult { - deployments := &appsv1.DeploymentList{} - - // Attempt to list all deployments across all namespaces. - // This will fail with permission denied for non-admin users. - err := client.List(ctx, deployments) +// 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 not logged in - this is a fatal error - if errors.IsUnauthorized(err) { - return DetectionResult{Error: fmt.Errorf("not logged in to cluster: %w", err)} + // 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 permission denied - indicates non-admin user - if errors.IsForbidden(err) { - 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 (cluster unreachable, etc.) - return DetectionResult{Error: fmt.Errorf("failed to query cluster: %w", err)} + // Other errors (oc not found, cluster unreachable, etc.) + return DetectionResult{Error: fmt.Errorf("failed to check permissions: %w", err)} } - // Filter deployments to find OADP controller - for _, deployment := range deployments.Items { - if deployment.Name == "openshift-adp-controller-manager" { - // Found OADP controller - user is admin - return DetectionResult{ - IsAdmin: true, - OADPNamespace: deployment.Namespace, - } - } + // 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} } - // OADP not installed - default to non-admin + // "no" means user cannot (non-admin mode) return DetectionResult{IsAdmin: false} } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index a17337ad..fb3d4fac 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -17,19 +17,15 @@ limitations under the License. package setup import ( - "context" "fmt" "os" "path/filepath" "strings" - "time" "github.com/migtools/oadp-cli/cmd/shared" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/vmware-tanzu/velero/pkg/client" - appsv1 "k8s.io/api/apps/v1" - kbclient "sigs.k8s.io/controller-runtime/pkg/client" ) // SetupOptions holds the options for the setup command @@ -38,7 +34,6 @@ type SetupOptions struct { // Internal state detectionResult DetectionResult - kbClient kbclient.Client } // BindFlags binds the flags to the command @@ -48,25 +43,7 @@ func (o *SetupOptions) BindFlags(flags *pflag.FlagSet) { // Complete completes the options func (o *SetupOptions) Complete(args []string, f client.Factory) error { - // Create Kubernetes client with apps/v1 types for deployment detection - kbClient, err := shared.NewClientWithScheme(f, shared.ClientOptions{ - IncludeCoreTypes: true, - Timeout: 10 * time.Second, // Prevent hanging on cluster connection issues - }) - if err != nil { - // Check if this is an authentication error - if strings.Contains(err.Error(), "Unauthorized") { - return fmt.Errorf("not logged in to cluster. Please run: oc login ") - } - return fmt.Errorf("failed to create Kubernetes client: %w", err) - } - - // Add apps/v1 types to the scheme for deployment access - if err := appsv1.AddToScheme(kbClient.Scheme()); err != nil { - return fmt.Errorf("failed to add apps/v1 types to scheme: %w", err) - } - - o.kbClient = kbClient + // No setup needed - detection uses oc CLI directly return nil } @@ -78,7 +55,7 @@ func (o *SetupOptions) Validate(c *cobra.Command, args []string, f client.Factor // Run executes the setup command func (o *SetupOptions) Run(c *cobra.Command, f client.Factory) error { - fmt.Println("Detecting OADP configuration...") + fmt.Println("Detecting user permissions...") fmt.Println() // Silence usage help on errors during Run (we provide clear error messages) @@ -103,8 +80,7 @@ func (o *SetupOptions) Run(c *cobra.Command, f client.Factory) error { } // Run detection - ctx := context.Background() - o.detectionResult = detectUserMode(ctx, o.kbClient) + o.detectionResult = detectUserMode() // Handle detection errors if o.detectionResult.Error != nil { @@ -136,15 +112,8 @@ func (o *SetupOptions) Run(c *cobra.Command, f client.Factory) error { // Update config based on detection result if o.detectionResult.IsAdmin { config.NonAdmin = false - config.OADPNamespace = o.detectionResult.OADPNamespace - // Set namespace to OADP namespace for admin mode - if config.Namespace == "" { - config.Namespace = o.detectionResult.OADPNamespace - } } else { config.NonAdmin = true - // Don't set OADP namespace for non-admin users - config.OADPNamespace = "" } // Write config file @@ -167,9 +136,6 @@ func (o *SetupOptions) printCurrentConfig(config *shared.ClientConfig) { fmt.Println("Current mode: non-admin") } else { fmt.Println("Current mode: admin") - if config.OADPNamespace != "" { - fmt.Printf("OADP namespace: %s\n", config.OADPNamespace) - } } fmt.Printf("Configuration file: %s\n", configPath) } @@ -180,7 +146,6 @@ func (o *SetupOptions) printSetupSuccess() { configPath := filepath.Join(homeDir, ".config", "velero", "config.json") if o.detectionResult.IsAdmin { - fmt.Printf("✓ Found OADP controller in namespace: %s\n", o.detectionResult.OADPNamespace) fmt.Println("✓ Admin mode enabled") fmt.Println() fmt.Printf("Configuration saved to: %s\n", configPath) @@ -189,7 +154,6 @@ func (o *SetupOptions) printSetupSuccess() { fmt.Println(" oc oadp backup create my-backup") fmt.Println(" oc oadp restore create my-restore") } else { - fmt.Println("✗ OADP controller deployment not accessible.") fmt.Println("✓ Non-admin mode enabled") fmt.Println() fmt.Printf("Configuration saved to: %s\n", configPath) @@ -197,9 +161,6 @@ func (o *SetupOptions) printSetupSuccess() { 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") - fmt.Println() - fmt.Println("Note: OADP controller deployment not found or you don't have") - fmt.Println("cluster-wide permissions. Non-admin mode uses namespace-scoped resources.") } } @@ -215,12 +176,10 @@ func NewSetupCommand(f client.Factory) *cobra.Command { This command detects whether you have cluster-wide admin permissions and automatically configures the OADP CLI to use the appropriate mode: -- Admin mode: Full access to OADP resources across all namespaces -- Non-admin mode: Namespace-scoped access using NonAdminBackup resources +- 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 if you can list the OADP controller deployment -across all namespaces. Admin users can see resources cluster-wide, while -non-admin users are limited to their 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 diff --git a/cmd/shared/factories.go b/cmd/shared/factories.go index 1311abbc..5d2097c7 100644 --- a/cmd/shared/factories.go +++ b/cmd/shared/factories.go @@ -28,10 +28,9 @@ import ( // ClientConfig represents the structure of the Velero client configuration file type ClientConfig struct { - Namespace string `json:"namespace"` - NonAdmin interface{} `json:"nonadmin,omitempty"` - DefaultNABSL string `json:"default-nabsl,omitempty"` - OADPNamespace string `json:"oadp_namespace,omitempty"` + Namespace string `json:"namespace"` + NonAdmin interface{} `json:"nonadmin,omitempty"` + DefaultNABSL string `json:"default-nabsl,omitempty"` } // IsNonAdmin returns true if the nonadmin configuration is enabled. From b518487122115102f6043e506f4dcb851e974927 Mon Sep 17 00:00:00 2001 From: Joseph Date: Fri, 6 Mar 2026 10:32:37 -0500 Subject: [PATCH 6/6] Fix config overwrite issue Signed-off-by: Joseph --- cmd/shared/factories.go | 67 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/cmd/shared/factories.go b/cmd/shared/factories.go index 5d2097c7..59cc1725 100644 --- a/cmd/shared/factories.go +++ b/cmd/shared/factories.go @@ -113,22 +113,60 @@ func ReadVeleroClientConfig() (*ClientConfig, error) { return &config, nil } -// WriteVeleroClientConfig writes the client configuration to ~/.config/velero/config.json -func WriteVeleroClientConfig(config *ClientConfig) error { +// 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 "", fmt.Errorf("failed to get user home directory: %w", err) } + return filepath.Join(homeDir, ".config", "velero", "config.json"), nil +} - configPath := filepath.Join(homeDir, ".config", "velero", "config.json") +// 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(config, "", " ") + data, err := json.MarshalIndent(configMap, "", " ") if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } @@ -140,3 +178,22 @@ func WriteVeleroClientConfig(config *ClientConfig) error { 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) +}