From 7a7e7bcc7bb10bfa2e73597004f26b6c220139b2 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Sun, 12 Jul 2026 14:50:06 +0200 Subject: [PATCH 1/8] feat(sync,cluster-version): KUBECONFIG env var support and informational output When no explicit kubeconfig flag or CLOUDCTL_* env var is set, fall back to the standard KUBECONFIG environment variable (multi-file merge, same as kubectl) instead of always using ~/.kube/config. A new resolveKubeconfig() helper in root.go detects the default value and returns "" when KUBECONFIG is set, letting client-go's standard loading rules handle multi-file merging. configWithContext() is updated to use NewDefaultClientConfigLoadingRules() when path is "". Both sync and cluster-version now print an informational summary line to stdout (and slog.Info to stderr) before the spinner/query showing which kubeconfig files, context, and namespace are active. Also adds the missing `update` command to the README Commands section and documents the --dry-run flag for sync. Signed-off-by: onuryilmaz --- README.md | 30 ++++++++++++++++---- cmd/cluster-version.go | 15 ++++++++-- cmd/cluster-version_test.go | 11 ++++++++ cmd/root.go | 32 +++++++++++++++++++-- cmd/root_test.go | 31 +++++++++++++++++++++ cmd/sync.go | 55 +++++++++++++++++++++++-------------- cmd/sync_test.go | 15 ++++++++++ 7 files changed, 160 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d526cb8..5f51752 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **Syncs kubeconfigs** — fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local `~/.kube/config`, handling OIDC token caching, deduplication, and prefix-based entry management - **Reports cluster versions** — queries the Kubernetes API version of any context, trying unauthenticated first for speed +- **Self-updates** — checks for and installs the latest cloudctl release from GitHub - **Structured output** — every command supports `--output text|json|yaml` for scripting and pipelines; interactive terminals get a spinner and a colour-coded table ## Installation @@ -44,6 +45,9 @@ cloudctl cluster-version --context # Print version info cloudctl version cloudctl version --output json # machine-readable + +# Update cloudctl to the latest release +cloudctl update ``` ## Output formats @@ -109,16 +113,18 @@ log-level: info ### `sync` -Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. +Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. Before connecting, it prints a summary line showing which kubeconfig files, context, and namespace are in use. + +The `--greenhouse-cluster-kubeconfig` and `--remote-cluster-kubeconfig` flags support the standard `KUBECONFIG` environment variable: when no explicit path is given, cloudctl defers to `KUBECONFIG` (multi-file merge, same as `kubectl`). ``` cloudctl sync -n [flags] Flags: - -k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: ~/.kube/config) + -k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: $KUBECONFIG or ~/.kube/config) -c, --greenhouse-cluster-context Context inside the Greenhouse kubeconfig -n, --greenhouse-cluster-namespace Greenhouse organization namespace (required) - -r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: ~/.kube/config) + -r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: $KUBECONFIG or ~/.kube/config) --remote-cluster-name Sync only this cluster (default: all ready clusters) --prefix Prefix for managed kubeconfig entries (default: cloudctl) --merge-identical-users Share a single auth entry for clusters with identical OIDC config (default: true) @@ -126,17 +132,20 @@ Flags: --kubelogin-path Path to kubelogin binary (default: kubelogin) --kubelogin-extra-args Extra flags passed to kubelogin --kubelogin-token-cache-dir OIDC token cache directory + --dry-run Preview changes without writing to the kubeconfig file ``` ### `cluster-version` -Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. +Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. Prints a summary line showing the kubeconfig source and context before querying. + +Respects the `KUBECONFIG` environment variable when no explicit `--kubeconfig` path is given. ``` cloudctl cluster-version [flags] Flags: - -k, --kubeconfig Path to kubeconfig file (default: ~/.kube/config) + -k, --kubeconfig Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config) -c, --context Context to query --timeout Maximum time to wait for the API server (default: 10s) ``` @@ -152,6 +161,17 @@ Flags: --short Print only the version number ``` +### `update` + +Checks for the latest cloudctl release on GitHub and installs it, replacing the current binary. + +``` +cloudctl update [flags] + +Flags: + --dry-run Check for a newer version without installing it +``` + ## Support, Feedback, Contributing This project is open to feature requests, bug reports, and contributions via [GitHub issues](https://github.com/cloudoperators/cloudctl/issues) and pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index a95d8ef..1cf0465 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "encoding/json" "fmt" + "log/slog" "net/http" "os" "strings" @@ -55,7 +56,7 @@ var ( ) func runClusterVersion(cmd *cobra.Command, args []string) error { - kubeconfig = viper.GetString("kubeconfig") + kubeconfig = resolveKubeconfig(viper.GetString("kubeconfig")) kubecontext = viper.GetString("context") timeoutStr := viper.GetString("timeout") @@ -74,13 +75,23 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { // current-context field from the kubeconfig. effectiveContext := kubecontext if effectiveContext == "" { - loadingRules := clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + var loadingRules *clientcmd.ClientConfigLoadingRules + if kubeconfig != "" { + loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + } else { + loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() + } raw, rawErr := loadingRules.Load() if rawErr == nil && raw != nil { effectiveContext = raw.CurrentContext } } + // Print informational line before querying the server. + infoMsg := fmt.Sprintf("Using kubeconfig: %s (context: %s)", displayKubeconfig(kubeconfig), effectiveContext) + slog.Info(infoMsg) + fmt.Fprintln(cmd.OutOrStdout(), infoMsg) + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() diff --git a/cmd/cluster-version_test.go b/cmd/cluster-version_test.go index af0e044..01f9e5e 100644 --- a/cmd/cluster-version_test.go +++ b/cmd/cluster-version_test.go @@ -14,6 +14,7 @@ import ( . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -128,3 +129,13 @@ func TestGetAuthenticatedVersion_NonOKStatus(t *testing.T) { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring("500")) } + +func TestClusterVersionKubeconfigFlag_DefaultEqualsRecommendedHomeFile(t *testing.T) { + g := NewWithT(t) + + // Verify that the --kubeconfig flag default equals clientcmd.RecommendedHomeFile + // so that resolveKubeconfig can detect "user did not explicitly set a path". + f := clusterVersionCmd.Flags().Lookup("kubeconfig") + g.Expect(f).ToNot(BeNil()) + g.Expect(f.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) +} diff --git a/cmd/root.go b/cmd/root.go index 3826043..db45800 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -90,10 +90,38 @@ func init() { rootCmd.AddCommand(updateCmd) } +// resolveKubeconfig returns the kubeconfig path to use. +// When the value equals the default (~/.kube/config) and the KUBECONFIG env var +// is set, it returns "" so that client-go uses its standard multi-file loading rules. +// An explicit non-default path is always returned as-is. +func resolveKubeconfig(flagValue string) string { + if flagValue != clientcmd.RecommendedHomeFile { + return flagValue + } + if os.Getenv("KUBECONFIG") != "" { + return "" + } + return flagValue +} + +// displayKubeconfig returns a human-readable label for the effective kubeconfig source. +// When path is "" the KUBECONFIG env var is active; otherwise the explicit path is shown. +func displayKubeconfig(path string) string { + if path == "" { + return "$KUBECONFIG (" + os.Getenv("KUBECONFIG") + ")" + } + return path +} + // configWithContext builds a rest.Config for the specified context name from the given kubeconfig path. +// When kubeconfigPath is empty, client-go's default loading rules are used (reads KUBECONFIG env var +// and falls back to ~/.kube/config). func configWithContext(contextName, kubeconfigPath string) (*rest.Config, error) { - loadingRules := &clientcmd.ClientConfigLoadingRules{ - ExplicitPath: kubeconfigPath, + var loadingRules *clientcmd.ClientConfigLoadingRules + if kubeconfigPath != "" { + loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath} + } else { + loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() } overrides := &clientcmd.ConfigOverrides{ CurrentContext: contextName, diff --git a/cmd/root_test.go b/cmd/root_test.go index 47203f6..7b0969a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/spf13/viper" + "k8s.io/client-go/tools/clientcmd" . "github.com/onsi/gomega" ) @@ -88,3 +89,33 @@ func TestMissingConfigurationFile(t *testing.T) { err := setupConfig() g.Expect(err).NotTo(BeNil()) } + +func TestResolveKubeconfig_ExplicitNonDefault(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/some/other/config") + + // An explicit non-default path must be returned unchanged regardless of KUBECONFIG. + result := resolveKubeconfig("/my/explicit/path") + g.Expect(result).To(Equal("/my/explicit/path")) +} + +func TestResolveKubeconfig_DefaultWithKUBECONFIGSet(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/env/kubeconfig") + + // When the value is the default and KUBECONFIG is set, return "" to let client-go handle it. + result := resolveKubeconfig(clientcmd.RecommendedHomeFile) + g.Expect(result).To(BeEmpty()) +} + +func TestResolveKubeconfig_DefaultWithoutKUBECONFIG(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "") + + // When KUBECONFIG is not set, return the default path as-is. + result := resolveKubeconfig(clientcmd.RecommendedHomeFile) + g.Expect(result).To(Equal(clientcmd.RecommendedHomeFile)) +} diff --git a/cmd/sync.go b/cmd/sync.go index a9ef402..a70c4f6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -114,10 +114,10 @@ Examples: func runSync(cmd *cobra.Command, args []string) error { // Use viper as a source of configuration - greenhouseClusterKubeconfig = viper.GetString("greenhouse-cluster-kubeconfig") + greenhouseClusterKubeconfig = resolveKubeconfig(viper.GetString("greenhouse-cluster-kubeconfig")) greenhouseClusterContext = viper.GetString("greenhouse-cluster-context") greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace") - remoteClusterKubeconfig = viper.GetString("remote-cluster-kubeconfig") + remoteClusterKubeconfig = resolveKubeconfig(viper.GetString("remote-cluster-kubeconfig")) remoteClusterName = viper.GetString("remote-cluster-name") prefix = viper.GetString("prefix") mergeIdenticalUsers = viper.GetBool("merge-identical-users") @@ -134,31 +134,33 @@ func runSync(cmd *cobra.Command, args []string) error { w := cmd.OutOrStdout() printer := output.New(format, output.IsTTYWriter(w), w) - if greenhouseClusterKubeconfig == "" { - return fmt.Errorf("greenhouse cluster kubeconfig path is empty") - } - - if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil { - return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err) + // When path is not empty (explicit file), verify it exists before proceeding. + if greenhouseClusterKubeconfig != "" { + if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil { + return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err) + } } if err := validateAuthType(authType, kubeloginPath); err != nil { return err } + // Print informational summary so the user knows which files/context/namespace are active. + ctxLabel := greenhouseClusterContext + if ctxLabel == "" { + ctxLabel = "(current context)" + } + infoMsg := fmt.Sprintf("Greenhouse: %s (context: %s, namespace: %s) → local: %s", + displayKubeconfig(greenhouseClusterKubeconfig), ctxLabel, greenhouseClusterNamespace, displayKubeconfig(remoteClusterKubeconfig)) + slog.Info(infoMsg) + fmt.Fprintln(cmd.OutOrStdout(), infoMsg) + var ( centralConfig *rest.Config ) - if greenhouseClusterContext != "" { - centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig) - if err != nil { - return fmt.Errorf("failed to build greenhouse kubeconfig with context %q from %q: %w", greenhouseClusterContext, greenhouseClusterKubeconfig, err) - } - } else { - centralConfig, err = clientcmd.BuildConfigFromFlags("", greenhouseClusterKubeconfig) - if err != nil { - return fmt.Errorf("failed to build greenhouse kubeconfig from %q: %w", greenhouseClusterKubeconfig, err) - } + centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig) + if err != nil { + return fmt.Errorf("failed to build greenhouse kubeconfig: %w", err) } // Create a scheme and register Greenhouse types. @@ -203,7 +205,12 @@ func runSync(cmd *cobra.Command, args []string) error { return printer.Print(buildSyncResult(nil, notReady)) } - localConfig, err := clientcmd.LoadFromFile(remoteClusterKubeconfig) + var localConfig *clientcmdapi.Config + if remoteClusterKubeconfig != "" { + localConfig, err = clientcmd.LoadFromFile(remoteClusterKubeconfig) + } else { + localConfig, err = clientcmd.NewDefaultClientConfigLoadingRules().Load() + } if err != nil { return fmt.Errorf("failed to load local kubeconfig: %w", err) } @@ -240,7 +247,15 @@ func runSync(cmd *cobra.Command, args []string) error { return printer.Print(buildDryRunResult(diff, localConfigBefore, localConfig)) } - if writeErr := writeConfig(localConfig, remoteClusterKubeconfig); writeErr != nil { + writeTarget := remoteClusterKubeconfig + if writeTarget == "" { + // Multi-file KUBECONFIG: write to the first path (kubectl convention). + if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + writeTarget = parts[0] + } + } + + if writeErr := writeConfig(localConfig, writeTarget); writeErr != nil { _ = printer.Print(buildFailedSyncResult(ready, notReady, writeErr)) return fmt.Errorf("failed to write merged kubeconfig: %w", writeErr) } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index cc44136..a0fa0ac 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -14,6 +14,7 @@ import ( greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "github.com/cloudoperators/cloudctl/cmd/output" @@ -985,3 +986,17 @@ func TestBuildAccessDiffs_NoChanges(t *testing.T) { g.Expect(accesses).To(BeEmpty()) } + +func TestSyncKubeconfigFlags_DefaultEqualsRecommendedHomeFile(t *testing.T) { + g := NewWithT(t) + + // Verify flag defaults are clientcmd.RecommendedHomeFile so resolveKubeconfig + // can detect "user did not explicitly set a path" via value comparison. + fGreenhouse := syncCmd.Flags().Lookup("greenhouse-cluster-kubeconfig") + g.Expect(fGreenhouse).ToNot(BeNil()) + g.Expect(fGreenhouse.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) + + fRemote := syncCmd.Flags().Lookup("remote-cluster-kubeconfig") + g.Expect(fRemote).ToNot(BeNil()) + g.Expect(fRemote.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) +} From e8a920729e006d999bda8d9eefd55e4339d0aecd Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Sun, 12 Jul 2026 15:05:47 +0200 Subject: [PATCH 2/8] fix(sync,cluster-version): address review comments - resolveKubeconfig: use viper.IsSet instead of value equality so that explicitly setting --kubeconfig to the default path is correctly treated as an explicit value and does not defer to KUBECONFIG - Remove fmt.Fprintln info line from stdout in sync and cluster-version; slog.Info (stderr) is sufficient and avoids corrupting --output json/yaml pipelines; also fixes the errcheck lint violations - Fail fast with a clear error when KUBECONFIG is set but yields no usable write target (e.g. leading path-list separator) Signed-off-by: onuryilmaz --- cmd/cluster-version.go | 8 +++----- cmd/root.go | 11 ++++++----- cmd/root_test.go | 23 ++++++++++++++--------- cmd/sync.go | 19 ++++++++++++------- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index 1cf0465..adf9d08 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -56,7 +56,7 @@ var ( ) func runClusterVersion(cmd *cobra.Command, args []string) error { - kubeconfig = resolveKubeconfig(viper.GetString("kubeconfig")) + kubeconfig = resolveKubeconfig("kubeconfig", viper.GetString("kubeconfig")) kubecontext = viper.GetString("context") timeoutStr := viper.GetString("timeout") @@ -87,10 +87,8 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { } } - // Print informational line before querying the server. - infoMsg := fmt.Sprintf("Using kubeconfig: %s (context: %s)", displayKubeconfig(kubeconfig), effectiveContext) - slog.Info(infoMsg) - fmt.Fprintln(cmd.OutOrStdout(), infoMsg) + // Log informational line before querying the server. + slog.Info("querying cluster version", "kubeconfig", displayKubeconfig(kubeconfig), "context", effectiveContext) ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() diff --git a/cmd/root.go b/cmd/root.go index db45800..4d895dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -91,11 +91,12 @@ func init() { } // resolveKubeconfig returns the kubeconfig path to use. -// When the value equals the default (~/.kube/config) and the KUBECONFIG env var -// is set, it returns "" so that client-go uses its standard multi-file loading rules. -// An explicit non-default path is always returned as-is. -func resolveKubeconfig(flagValue string) string { - if flagValue != clientcmd.RecommendedHomeFile { +// viperKey is the viper key for the flag (e.g. "kubeconfig", "greenhouse-cluster-kubeconfig"). +// When the key was not explicitly set by the user (via flag or CLOUDCTL_* env var) and the +// standard KUBECONFIG env var is set, it returns "" so that client-go uses its standard +// multi-file loading rules. An explicitly provided value is always returned as-is. +func resolveKubeconfig(viperKey, flagValue string) string { + if viper.IsSet(viperKey) { return flagValue } if os.Getenv("KUBECONFIG") != "" { diff --git a/cmd/root_test.go b/cmd/root_test.go index 7b0969a..72c68a6 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -90,32 +90,37 @@ func TestMissingConfigurationFile(t *testing.T) { g.Expect(err).NotTo(BeNil()) } -func TestResolveKubeconfig_ExplicitNonDefault(t *testing.T) { +func TestResolveKubeconfig_ExplicitlySet(t *testing.T) { g := NewWithT(t) t.Setenv("KUBECONFIG", "/some/other/config") + t.Cleanup(func() { viper.Reset() }) - // An explicit non-default path must be returned unchanged regardless of KUBECONFIG. - result := resolveKubeconfig("/my/explicit/path") + // When the viper key is explicitly set (simulating flag/CLOUDCTL_* env var), + // the provided value must be returned as-is regardless of KUBECONFIG. + viper.Set("kubeconfig", "/my/explicit/path") + result := resolveKubeconfig("kubeconfig", "/my/explicit/path") g.Expect(result).To(Equal("/my/explicit/path")) } -func TestResolveKubeconfig_DefaultWithKUBECONFIGSet(t *testing.T) { +func TestResolveKubeconfig_NotSetWithKUBECONFIG(t *testing.T) { g := NewWithT(t) t.Setenv("KUBECONFIG", "/env/kubeconfig") + t.Cleanup(func() { viper.Reset() }) - // When the value is the default and KUBECONFIG is set, return "" to let client-go handle it. - result := resolveKubeconfig(clientcmd.RecommendedHomeFile) + // When the key was not explicitly set and KUBECONFIG is set, return "" to let client-go handle it. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) g.Expect(result).To(BeEmpty()) } -func TestResolveKubeconfig_DefaultWithoutKUBECONFIG(t *testing.T) { +func TestResolveKubeconfig_NotSetWithoutKUBECONFIG(t *testing.T) { g := NewWithT(t) t.Setenv("KUBECONFIG", "") + t.Cleanup(func() { viper.Reset() }) - // When KUBECONFIG is not set, return the default path as-is. - result := resolveKubeconfig(clientcmd.RecommendedHomeFile) + // When KUBECONFIG is not set either, return the default path as-is. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) g.Expect(result).To(Equal(clientcmd.RecommendedHomeFile)) } diff --git a/cmd/sync.go b/cmd/sync.go index a70c4f6..b99b68f 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -114,10 +114,10 @@ Examples: func runSync(cmd *cobra.Command, args []string) error { // Use viper as a source of configuration - greenhouseClusterKubeconfig = resolveKubeconfig(viper.GetString("greenhouse-cluster-kubeconfig")) + greenhouseClusterKubeconfig = resolveKubeconfig("greenhouse-cluster-kubeconfig", viper.GetString("greenhouse-cluster-kubeconfig")) greenhouseClusterContext = viper.GetString("greenhouse-cluster-context") greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace") - remoteClusterKubeconfig = resolveKubeconfig(viper.GetString("remote-cluster-kubeconfig")) + remoteClusterKubeconfig = resolveKubeconfig("remote-cluster-kubeconfig", viper.GetString("remote-cluster-kubeconfig")) remoteClusterName = viper.GetString("remote-cluster-name") prefix = viper.GetString("prefix") mergeIdenticalUsers = viper.GetBool("merge-identical-users") @@ -145,15 +145,17 @@ func runSync(cmd *cobra.Command, args []string) error { return err } - // Print informational summary so the user knows which files/context/namespace are active. + // Log informational summary so the user knows which files/context/namespace are active. ctxLabel := greenhouseClusterContext if ctxLabel == "" { ctxLabel = "(current context)" } - infoMsg := fmt.Sprintf("Greenhouse: %s (context: %s, namespace: %s) → local: %s", - displayKubeconfig(greenhouseClusterKubeconfig), ctxLabel, greenhouseClusterNamespace, displayKubeconfig(remoteClusterKubeconfig)) - slog.Info(infoMsg) - fmt.Fprintln(cmd.OutOrStdout(), infoMsg) + slog.Info("syncing kubeconfigs", + "greenhouse", displayKubeconfig(greenhouseClusterKubeconfig), + "context", ctxLabel, + "namespace", greenhouseClusterNamespace, + "local", displayKubeconfig(remoteClusterKubeconfig), + ) var ( centralConfig *rest.Config @@ -253,6 +255,9 @@ func runSync(cmd *cobra.Command, args []string) error { if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { writeTarget = parts[0] } + if writeTarget == "" { + return fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") + } } if writeErr := writeConfig(localConfig, writeTarget); writeErr != nil { From a7ac8ed5de7b5cee04e1cd15e5f4547bd249f2e5 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Sun, 12 Jul 2026 19:57:19 +0200 Subject: [PATCH 3/8] fix(sync,cluster-version): address second round of review comments - cluster-version: add "(unknown)" fallback when effectiveContext is still empty after kubeconfig load (e.g. no current-context set) - root: fix displayKubeconfig to render ~/.kube/config instead of "$KUBECONFIG ()" when path is "" and KUBECONFIG is also unset - sync_test, cluster-version_test: update stale comments that referred to value-equality detection; now correctly describe viper.IsSet usage Signed-off-by: onuryilmaz --- cmd/cluster-version.go | 3 +++ cmd/cluster-version_test.go | 5 +++-- cmd/root.go | 6 +++++- cmd/sync_test.go | 5 +++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index adf9d08..815af57 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -86,6 +86,9 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { effectiveContext = raw.CurrentContext } } + if effectiveContext == "" { + effectiveContext = "(unknown)" + } // Log informational line before querying the server. slog.Info("querying cluster version", "kubeconfig", displayKubeconfig(kubeconfig), "context", effectiveContext) diff --git a/cmd/cluster-version_test.go b/cmd/cluster-version_test.go index 01f9e5e..8dc76e6 100644 --- a/cmd/cluster-version_test.go +++ b/cmd/cluster-version_test.go @@ -133,8 +133,9 @@ func TestGetAuthenticatedVersion_NonOKStatus(t *testing.T) { func TestClusterVersionKubeconfigFlag_DefaultEqualsRecommendedHomeFile(t *testing.T) { g := NewWithT(t) - // Verify that the --kubeconfig flag default equals clientcmd.RecommendedHomeFile - // so that resolveKubeconfig can detect "user did not explicitly set a path". + // Verify that the --kubeconfig flag default equals clientcmd.RecommendedHomeFile. + // resolveKubeconfig detects "user did not explicitly set a path" via viper.IsSet: + // when the flag is unset, viper returns the default and IsSet returns false. f := clusterVersionCmd.Flags().Lookup("kubeconfig") g.Expect(f).ToNot(BeNil()) g.Expect(f.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) diff --git a/cmd/root.go b/cmd/root.go index 4d895dc..082462e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -107,9 +107,13 @@ func resolveKubeconfig(viperKey, flagValue string) string { // displayKubeconfig returns a human-readable label for the effective kubeconfig source. // When path is "" the KUBECONFIG env var is active; otherwise the explicit path is shown. +// If path is "" and KUBECONFIG is also unset, client-go's default (~/.kube/config) is used. func displayKubeconfig(path string) string { if path == "" { - return "$KUBECONFIG (" + os.Getenv("KUBECONFIG") + ")" + if kc := os.Getenv("KUBECONFIG"); kc != "" { + return "$KUBECONFIG (" + kc + ")" + } + return clientcmd.RecommendedHomeFile } return path } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index a0fa0ac..7bb6f23 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -990,8 +990,9 @@ func TestBuildAccessDiffs_NoChanges(t *testing.T) { func TestSyncKubeconfigFlags_DefaultEqualsRecommendedHomeFile(t *testing.T) { g := NewWithT(t) - // Verify flag defaults are clientcmd.RecommendedHomeFile so resolveKubeconfig - // can detect "user did not explicitly set a path" via value comparison. + // Verify flag defaults are clientcmd.RecommendedHomeFile so that resolveKubeconfig + // can detect "user did not explicitly set a path" via viper.IsSet — when a flag is + // not set, viper returns the default, and IsSet returns false. fGreenhouse := syncCmd.Flags().Lookup("greenhouse-cluster-kubeconfig") g.Expect(fGreenhouse).ToNot(BeNil()) g.Expect(fGreenhouse.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) From 08261f14db957c61e7c6ab8066108d68a91ab9c8 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Sun, 12 Jul 2026 20:10:19 +0200 Subject: [PATCH 4/8] fix(sync,cluster-version): address third round of review comments - root: broaden resolveKubeconfig doc comment to include config-file as a valid explicit-set source (viper.IsSet covers all three) - cluster-version: restore kubeconfig source in error message so failures include which file/KUBECONFIG was in use - sync, cluster-version: reject explicitly-set empty kubeconfig values with a clear error instead of silently deferring to client-go defaults - sync_test: add table-driven unit tests for writeTarget KUBECONFIG selection logic (multi-file, leading separator, empty env var) - README: change "prints a summary line" to "logs a summary to stderr" for both sync and cluster-version sections Signed-off-by: onuryilmaz --- README.md | 4 +-- cmd/cluster-version.go | 7 ++++- cmd/root.go | 4 +-- cmd/sync.go | 9 ++++++ cmd/sync_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5f51752..9b209eb 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ log-level: info ### `sync` -Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. Before connecting, it prints a summary line showing which kubeconfig files, context, and namespace are in use. +Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. Before connecting, it logs a summary to stderr showing which kubeconfig files, context, and namespace are in use. The `--greenhouse-cluster-kubeconfig` and `--remote-cluster-kubeconfig` flags support the standard `KUBECONFIG` environment variable: when no explicit path is given, cloudctl defers to `KUBECONFIG` (multi-file merge, same as `kubectl`). @@ -137,7 +137,7 @@ Flags: ### `cluster-version` -Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. Prints a summary line showing the kubeconfig source and context before querying. +Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. Logs a summary to stderr showing the kubeconfig source and context before querying. Respects the `KUBECONFIG` environment variable when no explicit `--kubeconfig` path is given. diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index 815af57..70cffe9 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -59,6 +59,11 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { kubeconfig = resolveKubeconfig("kubeconfig", viper.GetString("kubeconfig")) kubecontext = viper.GetString("context") + // Reject an explicit empty-string value. + if viper.IsSet("kubeconfig") && kubeconfig == "" { + return fmt.Errorf("--kubeconfig must not be empty") + } + timeoutStr := viper.GetString("timeout") timeout, err := time.ParseDuration(timeoutStr) if err != nil { @@ -67,7 +72,7 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { cfg, err := configWithContext(kubecontext, kubeconfig) if err != nil { - return fmt.Errorf("failed to build kubeconfig with context %q: %w", kubecontext, err) + return fmt.Errorf("failed to build kubeconfig (source: %s, context: %q): %w", displayKubeconfig(kubeconfig), kubecontext, err) } // Resolve the actual context name used so the output is never empty. diff --git a/cmd/root.go b/cmd/root.go index 082462e..2af3a95 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -92,8 +92,8 @@ func init() { // resolveKubeconfig returns the kubeconfig path to use. // viperKey is the viper key for the flag (e.g. "kubeconfig", "greenhouse-cluster-kubeconfig"). -// When the key was not explicitly set by the user (via flag or CLOUDCTL_* env var) and the -// standard KUBECONFIG env var is set, it returns "" so that client-go uses its standard +// When the key was not explicitly set by the user (via flag, CLOUDCTL_* env var, or config file) +// and the standard KUBECONFIG env var is set, it returns "" so that client-go uses its standard // multi-file loading rules. An explicitly provided value is always returned as-is. func resolveKubeconfig(viperKey, flagValue string) string { if viper.IsSet(viperKey) { diff --git a/cmd/sync.go b/cmd/sync.go index b99b68f..ff01a61 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -118,6 +118,15 @@ func runSync(cmd *cobra.Command, args []string) error { greenhouseClusterContext = viper.GetString("greenhouse-cluster-context") greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace") remoteClusterKubeconfig = resolveKubeconfig("remote-cluster-kubeconfig", viper.GetString("remote-cluster-kubeconfig")) + + // Reject an explicit empty-string value — it would silently fall through to + // client-go default loading rules instead of failing fast. + if viper.IsSet("greenhouse-cluster-kubeconfig") && greenhouseClusterKubeconfig == "" { + return fmt.Errorf("--greenhouse-cluster-kubeconfig must not be empty") + } + if viper.IsSet("remote-cluster-kubeconfig") && remoteClusterKubeconfig == "" { + return fmt.Errorf("--remote-cluster-kubeconfig must not be empty") + } remoteClusterName = viper.GetString("remote-cluster-name") prefix = viper.GetString("prefix") mergeIdenticalUsers = viper.GetBool("merge-identical-users") diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 7bb6f23..a889ea3 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -6,8 +6,10 @@ package cmd import ( "bytes" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1" @@ -1001,3 +1003,66 @@ func TestSyncKubeconfigFlags_DefaultEqualsRecommendedHomeFile(t *testing.T) { g.Expect(fRemote).ToNot(BeNil()) g.Expect(fRemote.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) } + +func TestWriteTargetFromKUBECONFIG(t *testing.T) { + sep := string(os.PathListSeparator) + + tests := []struct { + name string + remoteKC string + kubeconfig string + wantTarget string + wantErrSubs string + }{ + { + name: "explicit remote path used as-is", + remoteKC: "/explicit/kubeconfig", + kubeconfig: "/env/kubeconfig", + wantTarget: "/explicit/kubeconfig", + }, + { + name: "empty remote: first KUBECONFIG entry used", + remoteKC: "", + kubeconfig: "/first" + sep + "/second", + wantTarget: "/first", + }, + { + name: "empty remote: leading separator yields error", + remoteKC: "", + kubeconfig: sep + "/second", + wantErrSubs: "cannot determine write target", + }, + { + name: "empty remote: KUBECONFIG also empty yields error", + remoteKC: "", + kubeconfig: "", + wantErrSubs: "cannot determine write target", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + t.Setenv("KUBECONFIG", tc.kubeconfig) + + writeTarget := tc.remoteKC + var writeErr error + if writeTarget == "" { + if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + writeTarget = parts[0] + } + if writeTarget == "" { + writeErr = fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") + } + } + + if tc.wantErrSubs != "" { + g.Expect(writeErr).To(HaveOccurred()) + g.Expect(writeErr.Error()).To(ContainSubstring(tc.wantErrSubs)) + } else { + g.Expect(writeErr).ToNot(HaveOccurred()) + g.Expect(writeTarget).To(Equal(tc.wantTarget)) + } + }) + } +} From 3315308184fcbd006748799c3dbd3abd347473c9 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Sun, 12 Jul 2026 20:18:16 +0200 Subject: [PATCH 5/8] fix(sync): include kubeconfig source and context in build error message Signed-off-by: onuryilmaz --- cmd/sync.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sync.go b/cmd/sync.go index ff01a61..09ae1a3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -171,7 +171,7 @@ func runSync(cmd *cobra.Command, args []string) error { ) centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig) if err != nil { - return fmt.Errorf("failed to build greenhouse kubeconfig: %w", err) + return fmt.Errorf("failed to build greenhouse kubeconfig (source: %s, context: %s): %w", displayKubeconfig(greenhouseClusterKubeconfig), ctxLabel, err) } // Create a scheme and register Greenhouse types. From ece2e0cdfd748473f9c5181cdc4b6cc1ffaceb11 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Mon, 13 Jul 2026 08:41:17 +0200 Subject: [PATCH 6/8] fix(sync,cluster-version,readme): address fourth round of review comments - cluster-version: use "(current context)" placeholder in error message when --context is not given, instead of showing an empty string - sync: extract resolveWriteTarget helper so the write-target logic is not duplicated between production code and the test - sync_test: update TestWriteTargetFromKUBECONFIG to call resolveWriteTarget directly; remove now-unused fmt/strings imports - README: correct update --check flag (was incorrectly documented as --dry-run) Signed-off-by: onuryilmaz --- README.md | 2 +- cmd/cluster-version.go | 6 +++++- cmd/sync.go | 26 +++++++++++++++++--------- cmd/sync_test.go | 13 +------------ 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9b209eb..6c83b41 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Checks for the latest cloudctl release on GitHub and installs it, replacing the cloudctl update [flags] Flags: - --dry-run Check for a newer version without installing it + --check Check for a newer version without installing it ``` ## Support, Feedback, Contributing diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index 70cffe9..df98f2b 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -72,7 +72,11 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { cfg, err := configWithContext(kubecontext, kubeconfig) if err != nil { - return fmt.Errorf("failed to build kubeconfig (source: %s, context: %q): %w", displayKubeconfig(kubeconfig), kubecontext, err) + ctxDisplay := kubecontext + if ctxDisplay == "" { + ctxDisplay = "(current context)" + } + return fmt.Errorf("failed to build kubeconfig (source: %s, context: %s): %w", displayKubeconfig(kubeconfig), ctxDisplay, err) } // Resolve the actual context name used so the output is never empty. diff --git a/cmd/sync.go b/cmd/sync.go index 09ae1a3..d9485c3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -258,15 +258,9 @@ func runSync(cmd *cobra.Command, args []string) error { return printer.Print(buildDryRunResult(diff, localConfigBefore, localConfig)) } - writeTarget := remoteClusterKubeconfig - if writeTarget == "" { - // Multi-file KUBECONFIG: write to the first path (kubectl convention). - if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { - writeTarget = parts[0] - } - if writeTarget == "" { - return fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") - } + writeTarget, writeTargetErr := resolveWriteTarget(remoteClusterKubeconfig) + if writeTargetErr != nil { + return writeTargetErr } if writeErr := writeConfig(localConfig, writeTarget); writeErr != nil { @@ -802,3 +796,17 @@ func validateAuthType(authType, kubeloginPath string) error { return fmt.Errorf("invalid --auth-type %q: must be one of \"auth-provider\" or \"exec-plugin\"", authType) } } + +// resolveWriteTarget returns the kubeconfig file path to write merged config into. +// When remoteKubeconfig is non-empty it is used directly. Otherwise the first +// path from the KUBECONFIG env var is used (kubectl convention for multi-file merge). +// An error is returned when no usable path can be determined. +func resolveWriteTarget(remoteKubeconfig string) (string, error) { + if remoteKubeconfig != "" { + return remoteKubeconfig, nil + } + if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + return parts[0], nil + } + return "", fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index a889ea3..601841b 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -6,10 +6,8 @@ package cmd import ( "bytes" "errors" - "fmt" "os" "path/filepath" - "strings" "testing" greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1" @@ -1045,16 +1043,7 @@ func TestWriteTargetFromKUBECONFIG(t *testing.T) { g := NewWithT(t) t.Setenv("KUBECONFIG", tc.kubeconfig) - writeTarget := tc.remoteKC - var writeErr error - if writeTarget == "" { - if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { - writeTarget = parts[0] - } - if writeTarget == "" { - writeErr = fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") - } - } + writeTarget, writeErr := resolveWriteTarget(tc.remoteKC) if tc.wantErrSubs != "" { g.Expect(writeErr).To(HaveOccurred()) From 21186cfec43cfb760a65de59a1a23cbb6ae4e269 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Mon, 13 Jul 2026 10:34:07 +0200 Subject: [PATCH 7/8] fix(sync): improve resolveWriteTarget error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the single catch-all error into two distinct cases: - KUBECONFIG is unset/empty → "...KUBECONFIG is empty" - KUBECONFIG is set but first entry is unusable → "KUBECONFIG= contains no usable first path" The previous message incorrectly claimed "KUBECONFIG is set" even when it was empty. Including the raw KUBECONFIG value in the second error makes it easier to debug path-list issues. Update TestWriteTargetFromKUBECONFIG wantErrSubs to match the new distinct substrings. Signed-off-by: onuryilmaz --- cmd/sync.go | 8 ++++++-- cmd/sync_test.go | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index d9485c3..5bc30b3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -805,8 +805,12 @@ func resolveWriteTarget(remoteKubeconfig string) (string, error) { if remoteKubeconfig != "" { return remoteKubeconfig, nil } - if parts := strings.SplitN(os.Getenv("KUBECONFIG"), string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + kc := os.Getenv("KUBECONFIG") + if kc == "" { + return "", fmt.Errorf("cannot determine write target: --remote-cluster-kubeconfig is not set and KUBECONFIG is empty") + } + if parts := strings.SplitN(kc, string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { return parts[0], nil } - return "", fmt.Errorf("cannot determine write target: KUBECONFIG is set but contains no usable path") + return "", fmt.Errorf("cannot determine write target: KUBECONFIG=%q contains no usable first path", kc) } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 601841b..fc89fb8 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1028,13 +1028,13 @@ func TestWriteTargetFromKUBECONFIG(t *testing.T) { name: "empty remote: leading separator yields error", remoteKC: "", kubeconfig: sep + "/second", - wantErrSubs: "cannot determine write target", + wantErrSubs: "contains no usable first path", }, { name: "empty remote: KUBECONFIG also empty yields error", remoteKC: "", kubeconfig: "", - wantErrSubs: "cannot determine write target", + wantErrSubs: "KUBECONFIG is empty", }, } From 2202d1422b37452ff629c12be9c3a535fb214454 Mon Sep 17 00:00:00 2001 From: onuryilmaz Date: Mon, 13 Jul 2026 11:00:23 +0200 Subject: [PATCH 8/8] test(root): add integration test for viper.IsSet with bound Cobra flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestResolveKubeconfig_ViperIsSetFalseForUnparsedCobraFlag, which binds clusterVersionCmd's flags to a viper instance and parses with no arguments. It asserts that viper.IsSet("kubeconfig") remains false when the flag is left at its default — guarding against a regression where Cobra/pflag/viper semantics could cause IsSet to return true for an unset-but-defaulted flag, which would break the KUBECONFIG env var fallback in resolveKubeconfig. Signed-off-by: onuryilmaz --- cmd/root_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/root_test.go b/cmd/root_test.go index 72c68a6..9f82b8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -124,3 +124,31 @@ func TestResolveKubeconfig_NotSetWithoutKUBECONFIG(t *testing.T) { result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) g.Expect(result).To(Equal(clientcmd.RecommendedHomeFile)) } + +func TestResolveKubeconfig_ViperIsSetFalseForUnparsedCobraFlag(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/env/kubeconfig") + t.Cleanup(func() { viper.Reset() }) + + // Bind clusterVersionCmd's flags to a fresh viper instance and parse + // with no arguments so the --kubeconfig flag is left at its default. + // viper.IsSet must remain false, meaning resolveKubeconfig defers to + // KUBECONFIG (returns ""). + // + // This guards against a regression where Cobra/pflag/viper semantics + // change such that binding a flag with a default causes IsSet to return + // true even when the user never touched the flag. + localViper := viper.New() + g.Expect(localViper.BindPFlags(clusterVersionCmd.Flags())).To(Succeed()) + g.Expect(clusterVersionCmd.ParseFlags([]string{})).To(Succeed()) + + g.Expect(localViper.IsSet("kubeconfig")).To(BeFalse(), + "viper.IsSet must be false for a flag that was not explicitly set by the user") + + // Confirm resolveKubeconfig returns "" (defer to KUBECONFIG) in this state. + // We use the global viper (which is what resolveKubeconfig reads) — it + // has not been set, so IsSet is also false there. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) + g.Expect(result).To(BeEmpty()) +}