Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package kubectl-gather for openSUSE:Factory checked in at 2026-07-31 15:29:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/kubectl-gather (Old) and /work/SRC/openSUSE:Factory/.kubectl-gather.new.2004 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "kubectl-gather" Fri Jul 31 15:29:40 2026 rev:8 rq:1368471 version:0.14.0 Changes: -------- --- /work/SRC/openSUSE:Factory/kubectl-gather/kubectl-gather.changes 2026-07-13 14:26:54.273032491 +0200 +++ /work/SRC/openSUSE:Factory/.kubectl-gather.new.2004/kubectl-gather.changes 2026-07-31 16:08:40.239274641 +0200 @@ -1,0 +2,12 @@ +Thu Jul 30 05:37:19 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.14.0: + * Highlights + - Support gathering from multiple kubeconfig files via a + comma-separated --kubeconfig list. + See Using separate kubeconfig files. + https://github.com/nirs/kubectl-gather#using-separate-kubeconfig-files + * What's Changed + - Support multiple kubeconfig files by @nirs in #165 + +------------------------------------------------------------------- Old: ---- kubectl-gather-0.13.1.obscpio New: ---- kubectl-gather-0.14.0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ kubectl-gather.spec ++++++ --- /var/tmp/diff_new_pack.bwqvwk/_old 2026-07-31 16:08:40.791293844 +0200 +++ /var/tmp/diff_new_pack.bwqvwk/_new 2026-07-31 16:08:40.795293983 +0200 @@ -17,7 +17,7 @@ Name: kubectl-gather -Version: 0.13.1 +Version: 0.14.0 Release: 0 Summary: Kubectl plugin to gather data about your cluster License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.bwqvwk/_old 2026-07-31 16:08:40.827295096 +0200 +++ /var/tmp/diff_new_pack.bwqvwk/_new 2026-07-31 16:08:40.831295236 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/nirs/kubectl-gather.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v0.13.1</param> + <param name="revision">refs/tags/v0.14.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.bwqvwk/_old 2026-07-31 16:08:40.859296209 +0200 +++ /var/tmp/diff_new_pack.bwqvwk/_new 2026-07-31 16:08:40.863296348 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/nirs/kubectl-gather</param> <param name="changesrevision">62c6089cb7cb3552a81b6f74b8394d4b1433fd70</param></service><service name="tar_scm"> <param name="url">https://github.com/nirs/kubectl-gather.git</param> - <param name="changesrevision">d7c350ae547cc357159abcacb28cb2d9e85f93db</param></service></servicedata> + <param name="changesrevision">8e26ef87c72fc49d9120a1b1eacc88500928f877</param></service></servicedata> (No newline at EOF) ++++++ kubectl-gather-0.13.1.obscpio -> kubectl-gather-0.14.0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/.golangci.yaml new/kubectl-gather-0.14.0/.golangci.yaml --- old/kubectl-gather-0.13.1/.golangci.yaml 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/.golangci.yaml 2026-07-29 15:34:14.000000000 +0200 @@ -5,7 +5,6 @@ linters: enable: - errcheck - - goconst - nolintlint # TODO: enable after fixing issues #- gocritic diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/README.md new/kubectl-gather-0.14.0/README.md --- old/kubectl-gather-0.13.1/README.md 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/README.md 2026-07-29 15:34:14.000000000 +0200 @@ -200,6 +200,69 @@ └── ceph-volume.log ``` +## Using separate kubeconfig files + +When working with multiple clusters you often get a separate kubeconfig +file per cluster, exported from CI or another system. These files +typically have duplicate entries and use the same generic names (e.g. +"admin" for every context and user), so they cannot be merged with +standard tools. + +To gather from separate kubeconfig files, pass them all to +`--kubeconfig`: + +``` +$ kubectl gather --kubeconfig ocp/hub.yaml,ocp/c1.yaml,ocp/c2.yaml -d gather.dr +2026-05-06T00:15:32.100+0300 INFO gather Using cluster "hub" from kubeconfig "ocp/hub.yaml" +2026-05-06T00:15:32.101+0300 INFO gather Using cluster "c1" from kubeconfig "ocp/c1.yaml" +2026-05-06T00:15:32.102+0300 INFO gather Using cluster "c2" from kubeconfig "ocp/c2.yaml" +2026-05-06T00:15:32.102+0300 INFO gather Gathering from all namespaces +2026-05-06T00:15:32.102+0300 INFO gather Gathering from cluster "hub" +2026-05-06T00:15:32.102+0300 INFO gather Gathering from cluster "c1" +2026-05-06T00:15:32.102+0300 INFO gather Gathering from cluster "c2" +... +``` + +When using multiple kubeconfig files, each file provides one cluster. +The cluster name is derived from the filename (without directory and +extension), and the actual context names inside the files (e.g. "admin") +are not used for naming. The gathered data is stored in one directory +per cluster: + +``` +$ tree -L 2 gather.dr/ +gather.dr/ +├── c1 +│ ├── cluster +│ └── namespaces +├── c2 +│ ├── cluster +│ └── namespaces +├── gather.log +└── hub + ├── cluster + └── namespaces +``` + +File names must be unique across all specified kubeconfig files, even +if the files are in different directories. + +The cluster name is always derived from the filename, even for a single +file: + +``` +$ kubectl gather --kubeconfig hub.yaml -d gather.one +2026-05-06T00:15:32.100+0300 INFO gather Using cluster "hub" from kubeconfig "hub.yaml" +2026-05-06T00:15:32.100+0300 INFO gather Gathering from all namespaces +2026-05-06T00:15:32.100+0300 INFO gather Gathering from cluster "hub" +... +``` + +Use `--contexts` to select specific contexts from a kubeconfig file. + +`kubectl gather` never modifies the provided kubeconfig files or the +default kubeconfig (`~/.kube/config`). + ## Gathering data for specific namespaces When debugging a problem, it is useful to gather data for specific diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/cmd/config.go new/kubectl-gather-0.14.0/cmd/config.go --- old/kubectl-gather-0.13.1/cmd/config.go 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/cmd/config.go 1970-01-01 01:00:00.000000000 +0100 @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: The kubectl-gather authors -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/clientcmd/api" -) - -type clusterConfig struct { - Config *rest.Config - Context string -} - -func loadClusterConfigs(contexts []string, kubeconfig string) ([]*clusterConfig, error) { - if len(contexts) == 0 { - restConfig, err := rest.InClusterConfig() - if err != rest.ErrNotInCluster { - if err != nil { - return nil, err - } - - log.Infof("Using in cluster config") - return []*clusterConfig{{Config: restConfig}}, nil - } - - log.Debugf("Not running in cluster") - } - - config, err := loadKubeconfig(kubeconfig) - if err != nil { - return nil, err - } - - if len(contexts) == 0 { - if config.CurrentContext == "" { - return nil, fmt.Errorf("no context specified and current context not set") - } - - log.Infof("Using current context %q", config.CurrentContext) - contexts = []string{config.CurrentContext} - } - - var configs []*clusterConfig - - for _, context := range contexts { - restConfig, err := clientcmd.NewNonInteractiveClientConfig( - *config, context, nil, nil).ClientConfig() - if err != nil { - return nil, err - } - - configs = append(configs, &clusterConfig{Config: restConfig, Context: context}) - } - - return configs, nil -} - -func loadKubeconfig(kubeconfig string) (*api.Config, error) { - if kubeconfig == "" { - kubeconfig = defaultKubeconfig() - } - log.Infof("Using kubeconfig %q", kubeconfig) - config, err := clientcmd.LoadFromFile(kubeconfig) - if err != nil { - return nil, err - } - return config, nil -} - -func defaultKubeconfig() string { - env := os.Getenv("KUBECONFIG") - if env != "" { - return env - } - return clientcmd.RecommendedHomeFile -} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/cmd/local.go new/kubectl-gather-0.14.0/cmd/local.go --- old/kubectl-gather-0.13.1/cmd/local.go 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/cmd/local.go 2026-07-29 15:34:14.000000000 +0200 @@ -9,6 +9,7 @@ "time" "github.com/nirs/kubectl-gather/pkg/gather" + "github.com/nirs/kubectl-gather/pkg/kubeconfig" ) type result struct { @@ -16,7 +17,7 @@ Err error } -func localGather(clusterConfigs []*clusterConfig) { +func localGather(clusterConfigs []*kubeconfig.Config) { start := time.Now() wg := sync.WaitGroup{} @@ -25,30 +26,30 @@ for i := range clusterConfigs { clusterConfig := clusterConfigs[i] - if clusterConfig.Context != "" { - log.Infof("Gathering from cluster %q", clusterConfig.Context) + if clusterConfig.Name != "" { + log.Infof("Gathering from cluster %q", clusterConfig.Name) } else { - log.Info("Gathering on cluster") + log.Info("Gathering in cluster") } start := time.Now() - directory := filepath.Join(directory, clusterConfig.Context) + clusterDir := filepath.Join(directory, clusterConfig.Name) options := gather.Options{ - Kubeconfig: kubeconfig, + Kubeconfig: clusterConfig.Kubeconfig, Context: clusterConfig.Context, Namespaces: namespaces, Addons: addons, Cluster: cluster, Salt: parsedSalt, - Log: log.Named(clusterConfig.Context), + Log: log.Named(clusterConfig.Name), } wg.Add(1) go func() { defer wg.Done() - g, err := gather.New(clusterConfig.Config, directory, options) + g, err := gather.New(clusterConfig.Config, clusterDir, options) if err != nil { results <- result{Err: err} return @@ -61,11 +62,11 @@ } elapsed := time.Since(start).Seconds() - if clusterConfig.Context != "" { + if clusterConfig.Name != "" { log.Infof("Gathered %d resources from cluster %q in %.3f seconds", - g.Count(), clusterConfig.Context, elapsed) + g.Count(), clusterConfig.Name, elapsed) } else { - log.Infof("Gathered %d resources on cluster in %.3f seconds", + log.Infof("Gathered %d resources in %.3f seconds", g.Count(), elapsed) } }() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/cmd/remote.go new/kubectl-gather-0.14.0/cmd/remote.go --- old/kubectl-gather-0.13.1/cmd/remote.go 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/cmd/remote.go 2026-07-29 15:34:14.000000000 +0200 @@ -13,9 +13,10 @@ "time" "github.com/nirs/kubectl-gather/pkg/gather" + "github.com/nirs/kubectl-gather/pkg/kubeconfig" ) -func remoteGather(clusterConfigs []*clusterConfig) { +func remoteGather(clusterConfigs []*kubeconfig.Config) { start := time.Now() wg := sync.WaitGroup{} @@ -23,12 +24,12 @@ for i := range clusterConfigs { clusterConfig := clusterConfigs[i] - directory := filepath.Join(directory, clusterConfig.Context) + clusterDir := filepath.Join(directory, clusterConfig.Name) wg.Add(1) go func() { defer wg.Done() - if err := runMustGather(clusterConfig.Context, directory); err != nil { + if err := runMustGather(clusterConfig, clusterDir); err != nil { errors <- err } }() @@ -45,13 +46,13 @@ len(clusterConfigs), time.Since(start).Seconds()) } -func runMustGather(context string, directory string) error { - log.Infof("Gathering on remote cluster %q", context) +func runMustGather(config *kubeconfig.Config, directory string) error { + log.Infof("Gathering on remote cluster %q", config.Name) start := time.Now() var stderr bytes.Buffer - cmd := mustGatherCommand(context, directory) + cmd := mustGatherCommand(config, directory) cmd.Stderr = &stderr log.Debugf("Running command: %s", cmd) @@ -61,21 +62,23 @@ elapsed := time.Since(start).Seconds() log.Infof("Gathered on remote cluster %q in %.3f seconds", - context, elapsed) + config.Name, elapsed) return nil } -func mustGatherCommand(context string, directory string) *exec.Cmd { +func mustGatherCommand(config *kubeconfig.Config, directory string) *exec.Cmd { args := []string{ "adm", "must-gather", "--image=" + gather.Image, - "--context=" + context, "--dest-dir=" + directory, } - if kubeconfig != "" { - args = append(args, "--kubeconfig="+kubeconfig) + if config.Context != "" { + args = append(args, "--context="+config.Context) + } + if config.Kubeconfig != "" { + args = append(args, "--kubeconfig="+config.Kubeconfig) } var remoteArgs []string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/cmd/root.go new/kubectl-gather-0.14.0/cmd/root.go --- old/kubectl-gather-0.13.1/cmd/root.go 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/cmd/root.go 2026-07-29 15:34:14.000000000 +0200 @@ -19,10 +19,11 @@ "sigs.k8s.io/yaml" "github.com/nirs/kubectl-gather/pkg/gather" + "github.com/nirs/kubectl-gather/pkg/kubeconfig" ) var directory string -var kubeconfig string +var kubeconfigs []string var contexts []string var namespaces []string var addons []string @@ -39,6 +40,9 @@ # store it in gather.{timestamp}. kubectl gather --kubeconfig my-kubeconfig + # Gather from multiple clusters, each with its own kubeconfig file. + kubectl gather --kubeconfig ocp/hub.yaml,ocp/c1.yaml,ocp/c2.yaml + # Gather data from all namespaces in clusters "dr1", "dr2" and "hub" and store # it in "gather.local/", using default kubeconfig (~/.kube/config). kubectl gather --contexts dr1,dr2,hub --directory gather.local @@ -87,8 +91,8 @@ // Don't set default kubeconfig, so kubeconfig is empty unless the user // specified the option. This is required to allow running remote commands // using in-cluster config. - rootCmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", - "the kubeconfig file to use") + rootCmd.Flags().StringSliceVar(&kubeconfigs, "kubeconfig", nil, + "comma separated list of kubeconfig files to use") rootCmd.Flags().StringSliceVar(&contexts, "contexts", nil, "comma separated list of contexts to gather data from") @@ -140,10 +144,24 @@ log.Fatal(err) } - clusterConfigs, err := loadClusterConfigs(contexts, kubeconfig) + clusterConfigs, err := kubeconfig.Load(contexts, kubeconfigs) if err != nil { log.Fatal(err) } + if len(kubeconfigs) > 0 { + for _, c := range clusterConfigs { + log.Infof("Using cluster %q from kubeconfig %q", c.Name, c.Kubeconfig) + } + } else if len(contexts) > 0 { + log.Infof("Using kubeconfig %q", clusterConfigs[0].Kubeconfig) + } else { + if clusterConfigs[0].Name == "" { + log.Infof("Using in cluster config") + } else { + log.Infof("Using kubeconfig %q", clusterConfigs[0].Kubeconfig) + log.Infof("Using current context %q", clusterConfigs[0].Name) + } + } if cmd.Flags().Changed("salt") { log.Infof("Using user-provided salt %q", salt) @@ -179,6 +197,10 @@ } func validateOptions(cmd *cobra.Command) error { + if len(contexts) > 0 && len(kubeconfigs) > 1 { + return fmt.Errorf("--contexts cannot be used with multiple --kubeconfig files") + } + if salt != "" { var err error parsedSalt, err = validateSalt(salt) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/e2e/gather_test.go new/kubectl-gather-0.14.0/e2e/gather_test.go --- old/kubectl-gather-0.13.1/e2e/gather_test.go 2026-07-13 01:01:21.000000000 +0200 +++ new/kubectl-gather-0.14.0/e2e/gather_test.go 2026-07-29 15:34:14.000000000 +0200 @@ -3,6 +3,7 @@ package e2e import ( + "os" "os/exec" "path/filepath" "strings" @@ -151,6 +152,50 @@ validateGatherAll(t, validate.New(outputDir).WithDataRoot(dataRoot)) } +func TestGatherMultipleKubeconfigs(dt *testing.T) { + t := test.WithLog(dt) + outputDir := "out/test-gather-multi-kubeconfig" + + kubeconfigs := exportKubeconfigs(t) + + cmd := exec.Command( + kubectlGather, + "--kubeconfig", strings.Join(kubeconfigs, ","), + "--directory", outputDir, + ) + if err := commands.Run(cmd, t.Log); err != nil { + t.Errorf("kubectl-gather failed: %s", err) + } + + validateGatherAll(t, validate.New(outputDir)) +} + +func TestGatherMultipleKubeconfigsRemote(dt *testing.T) { + t := test.WithLog(dt) + if _, err := exec.LookPath("oc"); err != nil { + t.Skip("oc not found, skipping remote test") + } + + outputDir := "out/test-gather-multi-kubeconfig-remote" + + kubeconfigs := exportKubeconfigs(t) + + cmd := exec.Command( + kubectlGather, + "--kubeconfig", strings.Join(kubeconfigs, ","), + "--remote", + "--directory", outputDir, + ) + if err := commands.Run(cmd, t.Log); err != nil { + t.Fatalf("kubectl-gather --remote failed: %s", err) + } + + dataRoot := findDataRoot(t, filepath.Join(outputDir, clusters.C1)) + t.Debugf("Data root: %s", dataRoot) + + validateGatherAll(t, validate.New(outputDir).WithDataRoot(dataRoot)) +} + func TestGatherClusterFalse(dt *testing.T) { t := test.WithLog(dt) outputDir := "out/test-gather-cluster-false" @@ -525,6 +570,36 @@ // Test helpers +// exportKubeconfigs exports a separate kubeconfig file for each cluster, +// simulating the multi-kubeconfig workflow where each file was exported from a +// different system. Files are named after the cluster (e.g. "c1.yaml") so the +// derived cluster name matches the expected directory layout. +func exportKubeconfigs(t *test.T) []string { + t.Helper() + + dir := t.TempDir() + + var paths []string + for _, name := range clusters.Names { + path := filepath.Join(dir, name+".yaml") + cmd := exec.Command( + "kubectl", "config", "view", + "--minify", + "--flatten", + "--context", name, + ) + out, err := cmd.Output() + if err != nil { + t.Fatalf("failed to export kubeconfig for context %q: %s", name, err) + } + if err := os.WriteFile(path, out, 0600); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + return paths +} + func validateGatherAll(t *test.T, v *validate.Validator) { t.Helper() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/pkg/kubeconfig/kubeconfig.go new/kubectl-gather-0.14.0/pkg/kubeconfig/kubeconfig.go --- old/kubectl-gather-0.13.1/pkg/kubeconfig/kubeconfig.go 1970-01-01 01:00:00.000000000 +0100 +++ new/kubectl-gather-0.14.0/pkg/kubeconfig/kubeconfig.go 2026-07-29 15:34:14.000000000 +0200 @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: The kubectl-gather authors +// SPDX-License-Identifier: Apache-2.0 + +// Package kubeconfig loads cluster configurations for kubectl-gather. +// +// The returned [Config] values vary by mode: +// +// Mode Name Context +// In-cluster "" "" +// Current context see below "" +// Explicit --contexts sanitized context context +// Multiple kubeconfigs sanitized filename "" +// +// Name is used for the output directory and logging. It is always +// sanitized for cross-platform filesystem compatibility (no ":", "\", +// "/", ".", or leading/trailing "-"). Empty Name means in-cluster mode +// (data goes directly to the gather directory). +// +// Context is passed to child processes as --context. Empty Context means +// use the file's current-context implicitly — no --context flag is passed. +// +// # Naming for current context (no --contexts specified) +// +// When gathering the current context, Name is chosen by checking whether +// the context name is already filesystem-safe. If sanitizeName(context) +// equals the original context, the context name is used — this gives +// meaningful names for minikube/kind clusters (e.g. "dr1", "hub"). If +// the context name requires sanitization (common with OpenShift contexts +// like "admin/api-cluster.example.com:6443/system:admin"), the kubeconfig +// filename is used instead as a shorter, predictable alternative. +// +// Examples: +// +// kubectl gather --kubeconfig hub.yaml +// (current-context: "admin/api-hub:6443/system:admin") +// → Name="hub" (context is not clean, use filename), Context="" +// +// kubectl gather +// (default ~/.kube/config, current-context: "dr1") +// → Name="dr1" (context is clean, use it), Context="" +// +// kubectl gather --contexts dr1,dr2 +// → Name="dr1", Context="dr1" +// → Name="dr2", Context="dr2" +// +// kubectl gather --kubeconfig hub.yaml,c1.yaml,c2.yaml +// → Name="hub", Context="" +// → Name="c1", Context="" +// → Name="c2", Context="" +package kubeconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Config holds the connection information and metadata for a single cluster. +type Config struct { + // Config is the Kubernetes REST client configuration for API access. + Config *rest.Config + + // Name is the cluster name for output directory and logging (empty for in-cluster). + Name string + + // Context is the context name for child processes (empty to use current-context). + Context string + + // Kubeconfig is the file path for child processes. + Kubeconfig string +} + +// Load loads cluster configurations based on the provided flags. It routes to +// the appropriate loading strategy: +// - contexts specified: load the given contexts from a single kubeconfig +// - kubeconfigs specified: load current-context from each file +// - neither: try in-cluster config, fallback to default kubeconfig +func Load(contexts, kubeconfigs []string) ([]*Config, error) { + if len(contexts) > 0 && len(kubeconfigs) > 1 { + return nil, fmt.Errorf("contexts cannot be used with multiple kubeconfig files") + } + + if len(contexts) > 0 { + var kc string + if len(kubeconfigs) == 1 { + kc = kubeconfigs[0] + } + return loadContexts(contexts, kc) + } + + if len(kubeconfigs) > 0 { + return loadKubeconfigs(kubeconfigs) + } + + configs, err := loadInCluster() + if err == nil { + return configs, nil + } + if err != rest.ErrNotInCluster { + return nil, err + } + + return loadCurrentContext() +} + +func loadInCluster() ([]*Config, error) { + restConfig, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + return []*Config{{Config: restConfig}}, nil +} + +// loadContexts loads explicit contexts from a single kubeconfig file. +func loadContexts(contexts []string, kubeconfig string) ([]*Config, error) { + if kubeconfig == "" { + kubeconfig = defaultKubeconfig() + } + + config, err := clientcmd.LoadFromFile(kubeconfig) + if err != nil { + return nil, err + } + + var configs []*Config + hosts := make(map[string]string) // host -> context + for _, context := range contexts { + restConfig, err := clientcmd.NewNonInteractiveClientConfig( + *config, context, nil, nil).ClientConfig() + if err != nil { + return nil, err + } + if prev, ok := hosts[restConfig.Host]; ok { + return nil, fmt.Errorf("duplicate cluster %q from contexts %q and %q", restConfig.Host, prev, context) + } + hosts[restConfig.Host] = context + configs = append(configs, &Config{ + Config: restConfig, + Name: sanitizeName(context), + Context: context, + Kubeconfig: kubeconfig, + }) + } + return configs, nil +} + +// loadCurrentContext loads the current-context from the default kubeconfig. +func loadCurrentContext() ([]*Config, error) { + kubeconfig := defaultKubeconfig() + + config, err := clientcmd.LoadFromFile(kubeconfig) + if err != nil { + return nil, err + } + if config.CurrentContext == "" { + return nil, fmt.Errorf("no context specified and current context not set") + } + + name := config.CurrentContext + if sanitizeName(name) != name { + name = nameFromFilename(kubeconfig) + } + + restConfig, err := clientcmd.NewNonInteractiveClientConfig( + *config, config.CurrentContext, nil, nil).ClientConfig() + if err != nil { + return nil, err + } + return []*Config{{ + Config: restConfig, + Name: name, + Kubeconfig: kubeconfig, + }}, nil +} + +// loadKubeconfigs loads each kubeconfig file independently, using current-context +// from each file. The cluster name is derived from the filename (without +// directory and extension). Context is left empty since each file's +// current-context is used implicitly. No merging or renaming is done. +func loadKubeconfigs(kubeconfigs []string) ([]*Config, error) { + names := make(map[string]string) // derived name -> file path + hosts := make(map[string]string) // host -> file path + var configs []*Config + + for _, path := range kubeconfigs { + name := nameFromFilename(path) + + if prev, ok := names[name]; ok { + return nil, fmt.Errorf("duplicate cluster name %q from files %q and %q", name, prev, path) + } + names[name] = path + + config, err := clientcmd.LoadFromFile(path) + if err != nil { + return nil, fmt.Errorf("loading %q: %w", path, err) + } + + context := config.CurrentContext + if context == "" { + return nil, fmt.Errorf("file %q: current-context not set", path) + } + + restConfig, err := clientcmd.NewNonInteractiveClientConfig( + *config, context, nil, nil).ClientConfig() + if err != nil { + return nil, fmt.Errorf("file %q: %w", path, err) + } + + if prev, ok := hosts[restConfig.Host]; ok { + return nil, fmt.Errorf("duplicate cluster %q from files %q and %q", restConfig.Host, prev, path) + } + hosts[restConfig.Host] = path + + configs = append(configs, &Config{ + Config: restConfig, + Name: name, + Kubeconfig: path, + }) + } + + return configs, nil +} + +// defaultKubeconfig returns the kubeconfig path from the KUBECONFIG environment +// variable or the default location. +func defaultKubeconfig() string { + env := os.Getenv("KUBECONFIG") + if env != "" { + return env + } + return clientcmd.RecommendedHomeFile +} + +// nameFromFilename extracts a cluster name from a kubeconfig file path by taking the +// filename without directory and extension, and sanitizing for cross-platform use. +func nameFromFilename(path string) string { + basename := filepath.Base(path) + name := strings.TrimSuffix(basename, filepath.Ext(basename)) + return sanitizeName(name) +} + +// sanitizeName replaces characters that are problematic in file paths to +// ensure the name works as a directory component on any platform. +func sanitizeName(name string) string { + name = strings.NewReplacer( + ":", "-", // invalid on Windows + "\\", "-", // path separator on Windows + "/", "-", // path separator on Unix + ".", "-", // hidden directory prefix, confusing in paths + ).Replace(name) + return strings.Trim(name, "-") +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kubectl-gather-0.13.1/pkg/kubeconfig/kubeconfig_test.go new/kubectl-gather-0.14.0/pkg/kubeconfig/kubeconfig_test.go --- old/kubectl-gather-0.13.1/pkg/kubeconfig/kubeconfig_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/kubectl-gather-0.14.0/pkg/kubeconfig/kubeconfig_test.go 2026-07-29 15:34:14.000000000 +0200 @@ -0,0 +1,607 @@ +// SPDX-FileCopyrightText: The kubectl-gather authors +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// In-cluster config is tested via e2e since rest.InClusterConfig() requires +// service account files on disk. + +type expectedConfig struct { + Name string + Context string + Kubeconfig string + Host string +} + +// kubectl gather (KUBECONFIG=hub.yaml, no --kubeconfig flag) +// Name derived from filename since current context is not filesystem-safe. +func TestLoadDefaultKubeconfigUnsafeContext(t *testing.T) { + dir := t.TempDir() + hubPath := writeKubeconfig(t, dir, "hub.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin/api-hub-example-com:6443/system:admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub-cluster +contexts: +- context: + cluster: hub-cluster + user: admin + name: admin/api-hub-example-com:6443/system:admin +users: +- name: admin + user: + token: token1 +`) + t.Setenv("KUBECONFIG", hubPath) + + configs, err := Load(nil, nil) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "hub", + Host: "https://hub.example.com:6443", + Kubeconfig: hubPath, + }, + }) +} + +// Name is the current context since it is already filesystem-safe. +// Context is empty to use the current context. +func TestLoadDefaultKubeconfigSafeContext(t *testing.T) { + dir := t.TempDir() + configPath := writeKubeconfig(t, dir, "config", ` +apiVersion: v1 +kind: Config +current-context: minikube +clusters: +- cluster: + server: https://192.168.49.2:8443 + name: minikube +contexts: +- context: + cluster: minikube + user: minikube + name: minikube +users: +- name: minikube + user: + token: token1 +`) + t.Setenv("KUBECONFIG", configPath) + + configs, err := Load(nil, nil) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "minikube", + Host: "https://192.168.49.2:8443", + Kubeconfig: configPath, + }, + }) +} + +// kubectl gather --contexts ... +// Name is the sanitized context name. Context is the raw value for child processes. +func TestLoadFromContexts(t *testing.T) { + // OCP clusters: context names are auto-generated and need sanitization. + t.Run("openshift", func(t *testing.T) { + dir := t.TempDir() + configPath := writeKubeconfig(t, dir, "config", ` +apiVersion: v1 +kind: Config +current-context: admin/api-hub-example-com:6443/system:admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub-cluster +- cluster: + server: https://dr1.example.com:6443 + name: dr1-cluster +- cluster: + server: https://dr2.example.com:6443 + name: dr2-cluster +contexts: +- context: + cluster: hub-cluster + user: admin + name: admin/api-hub-example-com:6443/system:admin +- context: + cluster: dr1-cluster + user: admin + name: admin/api-dr1-example-com:6443/system:admin +- context: + cluster: dr2-cluster + user: admin + name: admin/api-dr2-example-com:6443/system:admin +users: +- name: admin + user: + token: token1 +`) + t.Setenv("KUBECONFIG", configPath) + + contexts := []string{ + "admin/api-hub-example-com:6443/system:admin", + "admin/api-dr1-example-com:6443/system:admin", + "admin/api-dr2-example-com:6443/system:admin", + } + configs, err := Load(contexts, nil) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "admin-api-hub-example-com-6443-system-admin", + Context: "admin/api-hub-example-com:6443/system:admin", + Host: "https://hub.example.com:6443", + Kubeconfig: configPath, + }, + { + Name: "admin-api-dr1-example-com-6443-system-admin", + Context: "admin/api-dr1-example-com:6443/system:admin", + Host: "https://dr1.example.com:6443", + Kubeconfig: configPath, + }, + { + Name: "admin-api-dr2-example-com-6443-system-admin", + Context: "admin/api-dr2-example-com:6443/system:admin", + Host: "https://dr2.example.com:6443", + Kubeconfig: configPath, + }, + }) + }) + + // Minikube/drenv clusters: context names are clean and used as-is. + t.Run("drenv", func(t *testing.T) { + dir := t.TempDir() + configPath := writeKubeconfig(t, dir, "config", ` +apiVersion: v1 +kind: Config +current-context: hub +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub-cluster +- cluster: + server: https://dr1.example.com:6443 + name: dr1-cluster +- cluster: + server: https://dr2.example.com:6443 + name: dr2-cluster +contexts: +- context: + cluster: hub-cluster + user: admin + name: hub +- context: + cluster: dr1-cluster + user: admin + name: dr1 +- context: + cluster: dr2-cluster + user: admin + name: dr2 +users: +- name: admin + user: + token: token1 +`) + + configs, err := Load([]string{"hub", "dr1", "dr2"}, []string{configPath}) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "hub", + Context: "hub", + Host: "https://hub.example.com:6443", + Kubeconfig: configPath, + }, + { + Name: "dr1", + Context: "dr1", + Host: "https://dr1.example.com:6443", + Kubeconfig: configPath, + }, + { + Name: "dr2", + Context: "dr2", + Host: "https://dr2.example.com:6443", + Kubeconfig: configPath, + }, + }) + }) +} + +// kubectl gather --kubeconfig hub.yaml,spoke1.yaml +// Name derived from filename. Context is empty to use each file's current context. +func TestLoadFromMultipleKubeconfigs(t *testing.T) { + dir := t.TempDir() + + hubPath := writeKubeconfig(t, dir, "hub.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub-cluster +contexts: +- context: + cluster: hub-cluster + user: admin + name: admin +users: +- name: admin + user: + token: token1 +`) + + spoke1Path := writeKubeconfig(t, dir, "spoke1.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://spoke1.example.com:6443 + name: spoke-cluster +contexts: +- context: + cluster: spoke-cluster + user: admin + name: admin +users: +- name: admin + user: + token: token2 +`) + + configs, err := Load(nil, []string{hubPath, spoke1Path}) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "hub", + Host: "https://hub.example.com:6443", + Kubeconfig: hubPath, + }, + { + Name: "spoke1", + Host: "https://spoke1.example.com:6443", + Kubeconfig: spoke1Path, + }, + }) +} + +// kubectl gather --kubeconfig cluster1.yaml +// Name derived from filename. Uses current-context to select the right cluster +// from a file with stale entries. +func TestLoadFromKubeconfigSafeContext(t *testing.T) { + dir := t.TempDir() + + cluster1Path := writeKubeconfig(t, dir, "cluster1.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://current.example.com:6443 + name: current-cluster +- cluster: + server: https://old.example.com:6443 + name: old-cluster +contexts: +- context: + cluster: current-cluster + user: admin + name: admin +- context: + cluster: old-cluster + user: old-admin + name: old-admin +users: +- name: admin + user: + token: current-token +- name: old-admin + user: + token: old-token +`) + + configs, err := Load(nil, []string{cluster1Path}) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "cluster1", + Host: "https://current.example.com:6443", + Kubeconfig: cluster1Path, + }, + }) +} + +// kubectl gather --kubeconfig hub.yaml +// Name derived from filename since current context is not filesystem-safe. +func TestLoadFromKubeconfigUnsafeContext(t *testing.T) { + dir := t.TempDir() + hubPath := writeKubeconfig(t, dir, "hub.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin/api-hub-example-com:6443/system:admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub-cluster +contexts: +- context: + cluster: hub-cluster + user: admin + name: admin/api-hub-example-com:6443/system:admin +users: +- name: admin + user: + token: token1 +`) + + configs, err := Load(nil, []string{hubPath}) + if err != nil { + t.Fatal(err) + } + checkConfigs(t, configs, []expectedConfig{ + { + Name: "hub", + Host: "https://hub.example.com:6443", + Kubeconfig: hubPath, + }, + }) +} + +// TestDeriveName verifies that names derived from filenames are sanitized to safe directory names. +func TestDeriveNameFromFilename(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"ocp/hub.yaml", "hub"}, + {"ocp/hub.yml", "hub"}, + {"/home/user/configs/my-cluster.kubeconfig", "my-cluster"}, + {"cluster:6443.yaml", "cluster-6443"}, + {"plain", "plain"}, + {".hidden.yaml", "hidden"}, + {"my.cluster.yaml", "my-cluster"}, + } + for _, tt := range tests { + got := nameFromFilename(tt.path) + if got != tt.want { + t.Errorf("nameFromFilename(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +// --- negative cases --- + +// kubectl gather --contexts admin,viewer +// Error: both contexts point to the same cluster. +func TestLoadFromContextsDuplicateHost(t *testing.T) { + dir := t.TempDir() + configPath := writeKubeconfig(t, dir, "config", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub +contexts: +- context: + cluster: hub + user: admin + name: admin +- context: + cluster: hub + user: viewer + name: viewer +users: +- name: admin + user: + token: admin-token +- name: viewer + user: + token: viewer-token +`) + + _, err := Load([]string{"admin", "viewer"}, []string{configPath}) + if err == nil { + t.Fatal("expected error for duplicate host, got nil") + } +} + +// kubectl gather --kubeconfig my-kubeconfig +// Error: current-context not set and no --contexts specified. +func TestLoadFromKubconfigsNoCurrentContext(t *testing.T) { + dir := t.TempDir() + configPath := writeKubeconfig(t, dir, "config", ` +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://example.com:6443 + name: cluster +contexts: +- context: + cluster: cluster + user: admin + name: ctx +users: +- name: admin + user: + token: token +`) + + _, err := Load(nil, []string{configPath}) + if err == nil { + t.Fatal("expected error for missing current-context, got nil") + } +} + +// kubectl gather --kubeconfig dir1/cluster.yaml,dir2/cluster.yaml +// Error: duplicate basenames. +func TestLoadFromKubeconfigsDuplicateBasenames(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + + content := ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://example.com:6443 + name: cluster +contexts: +- context: + cluster: cluster + user: admin + name: admin +users: +- name: admin + user: + token: token +` + cluster1Path := writeKubeconfig(t, dir1, "cluster.yaml", content) + cluster2Path := writeKubeconfig(t, dir2, "cluster.yaml", content) + + _, err := Load(nil, []string{cluster1Path, cluster2Path}) + if err == nil { + t.Fatal("expected error for duplicate basenames, got nil") + } +} + +// kubectl gather --kubeconfig .hidden.yaml,hidden.yaml +// Error: names collide after sanitization. +func TestLoadFromKubeconfigsSanitizedCollision(t *testing.T) { + dir := t.TempDir() + + content := ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://example.com:6443 + name: cluster +contexts: +- context: + cluster: cluster + user: admin + name: admin +users: +- name: admin + user: + token: token +` + hiddenPath := writeKubeconfig(t, dir, ".hidden.yaml", content) + plainPath := writeKubeconfig(t, dir, "hidden.yaml", content) + + _, err := Load(nil, []string{hiddenPath, plainPath}) + if err == nil { + t.Fatal("expected error for sanitized name collision, got nil") + } +} + +// kubectl gather --kubeconfig hub.yaml,hub-backup.yaml +// Error: both files point to the same cluster. +func TestLoadFromKubeconfigsDuplicateHost(t *testing.T) { + dir := t.TempDir() + + hub1Path := writeKubeconfig(t, dir, "hub.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub +contexts: +- context: + cluster: hub + user: admin + name: admin +users: +- name: admin + user: + token: token1 +`) + + hub2Path := writeKubeconfig(t, dir, "hub-backup.yaml", ` +apiVersion: v1 +kind: Config +current-context: admin +clusters: +- cluster: + server: https://hub.example.com:6443 + name: hub +contexts: +- context: + cluster: hub + user: admin + name: admin +users: +- name: admin + user: + token: token2 +`) + + _, err := Load(nil, []string{hub1Path, hub2Path}) + if err == nil { + t.Fatal("expected error for duplicate host, got nil") + } +} + +// --- helpers --- + +func checkConfigs(t *testing.T, got []*Config, want []expectedConfig) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("expected %d configs, got %d", len(want), len(got)) + } + for i, w := range want { + if got[i].Name != w.Name { + t.Errorf("configs[%d].Name = %q, want %q", i, got[i].Name, w.Name) + } + if got[i].Context != w.Context { + t.Errorf("configs[%d].Context = %q, want %q", i, got[i].Context, w.Context) + } + if got[i].Kubeconfig != w.Kubeconfig { + t.Errorf("configs[%d].Kubeconfig = %q, want %q", i, got[i].Kubeconfig, w.Kubeconfig) + } + if got[i].Config.Host != w.Host { + t.Errorf("configs[%d].Config.Host = %q, want %q", i, got[i].Config.Host, w.Host) + } + } +} + +func writeKubeconfig(t *testing.T, dir, filename, content string) string { + t.Helper() + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return path +} ++++++ kubectl-gather.obsinfo ++++++ --- /var/tmp/diff_new_pack.bwqvwk/_old 2026-07-31 16:08:41.083304002 +0200 +++ /var/tmp/diff_new_pack.bwqvwk/_new 2026-07-31 16:08:41.091304280 +0200 @@ -1,5 +1,5 @@ name: kubectl-gather -version: 0.13.1 -mtime: 1783897281 -commit: d7c350ae547cc357159abcacb28cb2d9e85f93db +version: 0.14.0 +mtime: 1785332054 +commit: 8e26ef87c72fc49d9120a1b1eacc88500928f877 ++++++ vendor.tar.gz ++++++
