diff --git a/.gitignore b/.gitignore index bd2d8ed..befa29e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ kubectl-oadp-linux-* kubectl-oadp-darwin-* kubectl-oadp-windows-* +# Must-gather artifacts +/must-gather/ + # Local development tools bin/ diff --git a/Makefile b/Makefile index 32cd865..7cfa11c 100644 --- a/Makefile +++ b/Makefile @@ -238,8 +238,8 @@ install: build ## Build and install the kubectl plugin to ~/.local/bin (no sudo echo " export PATH=\"$(INSTALL_PATH):$$PATH\""; \ echo ""; \ echo " Quick start:"; \ - echo " • kubectl oadp --help # Show available commands"; \ - echo " • kubectl oadp backup get # List backups"; \ + echo " • oc oadp --help # Show available commands"; \ + echo " • oc oadp backup get # List backups"; \ else \ echo " ├─ ❌ Installation verification failed: kubectl oadp plugin not found"; \ echo " │ └─ Try running: export PATH=\"$(INSTALL_PATH):$$PATH\""; \ @@ -265,9 +265,9 @@ install: build ## Build and install the kubectl plugin to ~/.local/bin (no sudo echo "🎉 Installation complete!"; \ echo ""; \ echo " Quick start:"; \ - echo " • kubectl oadp --help # Show available commands"; \ - echo " • kubectl oadp backup get # List backups"; \ - echo " • kubectl oadp version # Show version info"; \ + echo " • oc oadp --help # Show available commands"; \ + echo " • oc oadp backup get # List backups"; \ + echo " • oc oadp version # Show version info"; \ else \ echo " └─ ❌ Installation verification failed: kubectl oadp plugin not found"; \ fi; \ @@ -284,7 +284,7 @@ install-user: build ## Build and install the kubectl plugin to ~/.local/bin (no cp $(BINARY_NAME) ~/.local/bin/ @echo "✅ Installed to ~/.local/bin" @echo "Add to PATH: export PATH=\"\$$HOME/.local/bin:\$$PATH\"" - @echo "Test: kubectl oadp --help" + @echo "Test: oc oadp --help" .PHONY: install-bin install-bin: build ## Build and install the kubectl plugin to ~/bin (no sudo required) @@ -293,14 +293,14 @@ install-bin: build ## Build and install the kubectl plugin to ~/bin (no sudo req cp $(BINARY_NAME) ~/bin/ @echo "✅ Installed to ~/bin" @echo "Add to PATH: export PATH=\"\$$HOME/bin:\$$PATH\"" - @echo "Test: kubectl oadp --help" + @echo "Test: oc oadp --help" .PHONY: install-system install-system: build ## Build and install the kubectl plugin to /usr/local/bin (requires sudo) @echo "Installing $(BINARY_NAME) to /usr/local/bin..." @sudo mv $(BINARY_NAME) /usr/local/bin/ @echo "✅ Installed to /usr/local/bin" - @echo "Test: kubectl oadp --help" + @echo "Test: oc oadp --help" .PHONY: uninstall uninstall: ## Uninstall the kubectl plugin from user locations diff --git a/cmd/must-gather/must_gather.go b/cmd/must-gather/must_gather.go new file mode 100644 index 0000000..2e6da67 --- /dev/null +++ b/cmd/must-gather/must_gather.go @@ -0,0 +1,186 @@ +/* +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 mustgather + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/vmware-tanzu/velero/pkg/client" +) + +// MustGatherOptions holds the options for the must-gather command +type MustGatherOptions struct { + DestDir string + RequestTimeout time.Duration + SkipTLS bool + Image string + + // Internal state + effectiveImage string // Resolved image (after version detection or default) +} + +// BindFlags binds the flags to the command +func (o *MustGatherOptions) BindFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.DestDir, "dest-dir", "", "Directory where must-gather output will be stored (defaults to current directory)") + flags.DurationVar(&o.RequestTimeout, "request-timeout", 0, "Timeout for the gather script (e.g., '1m', '30s')") + flags.BoolVar(&o.SkipTLS, "skip-tls", false, "Skip TLS verification") + flags.StringVar(&o.Image, "image", "", "Must-gather image to use (defaults to OADP must-gather image)") + _ = flags.MarkHidden("image") // Hidden flag for advanced users +} + +// Complete completes the options +func (o *MustGatherOptions) Complete(args []string, f client.Factory) error { + // Determine effective image to use + // For v1: Use hardcoded default if --image not specified + if o.Image == "" { + o.effectiveImage = "registry.redhat.io/oadp/oadp-mustgather-rhel9:v1.5" + // TODO: Future enhancement - detect version and map to image + // o.effectiveImage = o.getDefaultImage() + } else { + o.effectiveImage = o.Image + } + + return nil +} + +// Validate validates the options +func (o *MustGatherOptions) Validate(c *cobra.Command, args []string, f client.Factory) error { + // Verify oc command exists + if _, err := exec.LookPath("oc"); err != nil { + return fmt.Errorf("'oc' command not found in PATH. Install OpenShift CLI from: https://mirror.openshift.com/pub/openshift-v4/clients/ocp/") + } + + // Validate dest-dir if specified + if o.DestDir != "" { + if !filepath.IsAbs(o.DestDir) { + // Convert to absolute path + absPath, err := filepath.Abs(o.DestDir) + if err != nil { + return fmt.Errorf("invalid dest-dir path: %w", err) + } + o.DestDir = absPath + } + } + + return nil +} + +// Run executes the must-gather command +func (o *MustGatherOptions) Run(c *cobra.Command, f client.Factory) error { + // Build command arguments + args := []string{"adm", "must-gather", "--image=" + o.effectiveImage} + + // Add dest-dir if specified, otherwise use ./must-gather + if o.DestDir != "" { + args = append(args, "--dest-dir="+o.DestDir) + } else { + args = append(args, "--dest-dir=./must-gather") + } + + // Add gather script arguments if any flags are set + if o.RequestTimeout > 0 || o.SkipTLS { + args = append(args, "--") + args = append(args, "/usr/bin/gather") + + if o.RequestTimeout > 0 { + // Format duration for gather script (e.g., "1m", "30s") + args = append(args, "--request-timeout", o.RequestTimeout.String()) + } + + if o.SkipTLS { + args = append(args, "--skip-tls") + } + } + + // Execute with real-time output streaming + cmd := exec.Command("oc", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return o.formatError(err) + } + + return nil +} + +// formatError formats error messages for common failure scenarios +func (o *MustGatherOptions) formatError(err error) error { + // Check if oc not installed (shouldn't happen as we validate, but defensive) + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("'oc' command not found. Install OpenShift CLI from: https://mirror.openshift.com/pub/openshift-v4/clients/ocp/") + } + + // Check exit code for permission/auth issues + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("must-gather failed with exit code %d. Check that you're logged in and have appropriate permissions", exitErr.ExitCode()) + } + + return fmt.Errorf("must-gather failed: %w", err) +} + +// NewMustGatherCommand creates the must-gather command +func NewMustGatherCommand(f client.Factory) *cobra.Command { + o := &MustGatherOptions{} + + c := &cobra.Command{ + Use: "must-gather", + Short: "Collect diagnostic information for OADP", + Long: `Collect diagnostic information for OADP installations. + +This command runs the OADP must-gather tool to collect logs and cluster state +information needed for troubleshooting and support cases. The diagnostic bundle +will be saved to the specified directory (or current directory by default). + +Examples: + # Collect diagnostics to current directory + oc oadp must-gather + + # Collect diagnostics to a specific directory + oc oadp must-gather --dest-dir=/tmp/oadp-diagnostics + + # Collect diagnostics with custom timeout + oc oadp must-gather --request-timeout=30s + + # Collect diagnostics and skip TLS verification + oc oadp must-gather --skip-tls + + # Combine multiple options + oc oadp must-gather --dest-dir=/tmp/output --request-timeout=1m --skip-tls`, + 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/must-gather/must_gather_test.go b/cmd/must-gather/must_gather_test.go new file mode 100644 index 0000000..bb1887c --- /dev/null +++ b/cmd/must-gather/must_gather_test.go @@ -0,0 +1,115 @@ +/* +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 mustgather_test + +import ( + "strings" + "testing" + + "github.com/migtools/oadp-cli/internal/testutil" +) + +// TestMustGatherHelp verifies that the help command displays expected content +func TestMustGatherHelp(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + expectContains := []string{ + "Collect diagnostic information", + "--dest-dir", + "--request-timeout", + "--skip-tls", + "Examples:", + } + + testutil.TestHelpCommand(t, binaryPath, []string{"must-gather", "--help"}, expectContains) +} + +// TestMustGatherHelpFlags verifies that both --help and -h work +func TestMustGatherHelpFlags(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + // Test both --help and -h work + for _, flag := range []string{"--help", "-h"} { + t.Run(flag, func(t *testing.T) { + output, err := testutil.RunCommand(t, binaryPath, "must-gather", flag) + if err != nil { + t.Errorf("Help command with %s failed: %v", flag, err) + } + if !strings.Contains(output, "Usage:") { + t.Errorf("Help output missing Usage section") + } + if !strings.Contains(output, "Collect diagnostic information") { + t.Errorf("Help output missing description") + } + }) + } +} + +// TestMustGatherHelpContent verifies specific help text content +func TestMustGatherHelpContent(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + output, err := testutil.RunCommand(t, binaryPath, "must-gather", "--help") + if err != nil { + t.Fatalf("Help command failed: %v", err) + } + + // Verify all flags are documented + requiredContent := []string{ + "--dest-dir", + "--request-timeout", + "--skip-tls", + "Directory where must-gather output will be stored", + "Timeout for the gather script", + "Skip TLS verification", + } + + for _, content := range requiredContent { + if !strings.Contains(output, content) { + t.Errorf("Help output missing expected content: %q", content) + } + } + + // Verify --image flag is hidden (should not appear in help) + if strings.Contains(output, "--image") { + t.Errorf("Hidden flag --image should not appear in help output") + } +} + +// TestMustGatherExamples verifies that the help text includes examples +func TestMustGatherExamples(t *testing.T) { + binaryPath := testutil.BuildCLIBinary(t) + + output, err := testutil.RunCommand(t, binaryPath, "must-gather", "--help") + if err != nil { + t.Fatalf("Help command failed: %v", err) + } + + // Verify examples are present + examples := []string{ + "oc oadp must-gather", + "--dest-dir=/tmp/oadp-diagnostics", + "--request-timeout=30s", + "--skip-tls", + } + + for _, example := range examples { + if !strings.Contains(output, example) { + t.Errorf("Help output missing example: %q", example) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index ac757f1..40e8263 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,7 @@ import ( "time" "github.com/fatih/color" + 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/spf13/cobra" @@ -418,11 +419,14 @@ func NewVeleroRootCommand(baseName string) *cobra.Command { // Custom subcommands - use NonAdmin factory c.AddCommand(nonadmin.NewNonAdminCommand(f)) + // Must-gather command - diagnostic tool + c.AddCommand(mustgather.NewMustGatherCommand(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" { + if cmd.Use == "nonadmin" || cmd.Use == "nabsl-request" || cmd.Use == "must-gather" { continue } replaceVeleroWithOADP(cmd)